The admin Users table (/admin?tab=users) is meant to show which organizations a CRM user belongs to, but the Organization column renders blank for every user. The list endpoint never returns organization memberships, and the column reads a user.organizationId field that no longer exists after the multi-organization migration. This ticket fixes that by returning each user's active organization memberships from the list endpoint, rendering them in the table (capped so a user in many organizations does not overflow the row), adding an Active/Inactive status column, and adding a status filter above the table. Active/Inactive is derived from organization membership, since users have no standalone active flag.
The admin page renders <UsersTab /> when tab=users (sherpa-portal/app/admin/page.tsx:38). The table is defined in sherpa-portal/app/components/admin/UsersTab.tsx.
Current columns (UsersTab.tsx:344-398): Name, Email, Role, Organization, Created At, 2FA Status, Actions.
Two concrete defects and gaps:
getOrganizationName(user.organizationId) (UsersTab.tsx:434), which looks up a single organizationId against the org list. The organizationId column was dropped from the users table during the multi-org migration (sherpa-api/src/db/migrations/2025.10.16T20.00.00.drop-organization-id-from-users-final.ts; memberships now live in the user_organizations join table created by 2025.09.19T00.00.01.create-user-organizations.ts). So user.organizationId is undefined and the cell is empty. Separately, the list endpoint getAllUsers (sherpa-api/src/controllers/userController.ts:73) only joins user_organizations when an organizationId filter is passed, and even then with attributes: [] (userController.ts:162-175), so the returned user rows carry no memberships at all. By contrast getUserById does include them (userController.ts:221-229), which is why a single user's detail page can show organizations while the list cannot.User model has only deleted (sherpa-api/src/models/User.ts:165), no active/status column. The only activity signal is per-membership UserOrganization.isActive (used in getUserById at userController.ts:225 and typed in the portal as UserOrganizationMembership.UserOrganization.isActive, sherpa-portal/app/types/user.ts:3-12).UsersTab.tsx:315-338), but nothing to filter by active vs inactive. The list query schema (sherpa-api/src/schemas/plus/user.schemas.ts:24-33) supports page, limit, search, sortField, sortOrder, role, userType, organizationId, but no status parameter.Endpoint path: the portal admin client rewrites admin calls to the /v1/plus prefix (sherpa-portal/app/src/utils/api.ts:192), and the plus router mounts user routes at /users (sherpa-api/src/routes/plus/index.ts, registered at /v1/plus in sherpa-api/src/routes.ts:244). So the table loads from GET /v1/plus/users (getAllUsers, route sherpa-api/src/routes/plus/userRoutes.ts:141).
The UserData type already declares organizations?: UserOrganizationMembership[] (sherpa-portal/app/types/user.ts:40), so the client type is ready; the data is simply never sent.
No live screenshots were captured for this draft; current behaviour above is grounded in source. See Open questions.
Endpoint: GET /v1/plus/users (getAllUsers, sherpa-api/src/controllers/userController.ts:73).
organizations include to the queryOptions built at userController.ts:150-158, mirroring getUserById (userController.ts:221-229): include the Organization association as: 'organizations' with through: { attributes: ['role', 'isActive', 'createdAt'], where: { isActive: true } } and attributes: ['id', 'displayName', 'name']. Use separate: true on this include so the association is fetched in its own query and does not multiply rows or corrupt the findAndCountAll total used for pagination. The endpoint contract becomes: each user row includes an organizations array of that user's active memberships (empty array if none).status filter. Extend getAllUsersQuerystringSchema (sherpa-api/src/schemas/plus/user.schemas.ts:24) and the route Querystring type (sherpa-api/src/routes/plus/userRoutes.ts:129-139) with status: 'active' | 'inactive' | 'all' (optional, default all). In getAllUsers, when status === 'active', constrain the user whereClause (userController.ts:122) so id is in the set of users that have at least one user_organizations row with isActive = true (a literal subquery with Op.in). When status === 'inactive', use the complement (Op.notIn of the same subquery), which selects users with zero active memberships. all or absent applies no status constraint. This must compose with the existing organizationId and search filters and with pagination (the totalUsers/totalPages counts must reflect the filter).deleted: false constraint (userController.ts:123) is unchanged; soft-deleted users stay excluded.File: sherpa-portal/app/components/admin/UsersTab.tsx.
UsersTab.tsx:377 from t("Organization") to t("Organizations"). Replace the cell body at UsersTab.tsx:433-435 with a render of user.organizations (the active memberships now returned by the API): show up to 2 organization names as chips, then a +{n} more chip when there are more, with the full list in a tooltip. When the array is empty, render an em-free placeholder dash (-) in muted text. Stop calling getOrganizationName(user.organizationId); that helper (UsersTab.tsx:121-125) and the per-row use of user.organizationId are no longer needed for this column.t("Status") and a new cell after the Organizations column. Active when user.organizations has at least one entry, otherwise Inactive. Render as a pill matching the adjacent 2FA pill (UsersTab.tsx:440-448): green for t("Active"), red for t("Inactive").UsersTab.tsx:315-338). Options: t("All"), t("Active"), t("Inactive"), defaulting to All. Hold the value in new state (statusFilter, default "all"), reset currentPage to 1 on change (as the org filter does at UsersTab.tsx:320-323), append &status=${statusFilter} to the request in fetchUsers when not all (UsersTab.tsx:136-138), and add statusFilter to the fetchUsers dependency array (UsersTab.tsx:150-161).UsersTab.tsx:403 and UsersTab.tsx:409). Adding the Status column makes 8; update both to colSpan={8}.The existing organizations fetch (UsersTab.tsx:167-175) stays, since the Organization filter Select still needs the full org list.
Not applicable. This is an internal admin surface only.
None. All required data (user_organizations.isActive) already exists.
Defer to the sherpa-design-system skill; reuse existing primitives in sherpa-portal/app/components/ui/.
Organizations cell.
displayName if present, else name), then a +{n} more chip where n is the remaining count. This caps row height whether a user has 1 or 100+ organizations.inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium). Use the brand light blue from the design system for org chips (bg-blue-50 text-blue-700, blue-50 is #EAF5FC). Add gap-1 between chips and allow wrap.+{n} more chip is wrapped in the Tooltip from sherpa-portal/app/components/ui/tooltip. On hover/focus it shows the full list of active organization names (one per line, vertically scrollable if long). The chip is keyboard-focusable so the tooltip is reachable without a mouse.-, text-gray-400), not an empty cell.Status cell.
UsersTab.tsx:440-448): bg-green-100 text-green-800 showing t("Active") when the user has at least one active membership, bg-red-100 text-red-800 showing t("Inactive") otherwise. The text label carries the meaning, not color alone.Status filter.
Select (sherpa-portal/app/components/ui/select) placed next to the Organization filter under the search box, with a visible label reading t("Status") (mirror the Organization label at UsersTab.tsx:317). Trigger width consistent with the Organization filter (w-64 or w-48). Options in order: t("All"), t("Active"), t("Inactive"), default All.Copy (exact, translated via t()): column headers Organizations and Status; pills Active / Inactive; overflow chip +{n} more; filter label Status with options All, Active, Inactive.
States. Loading and empty rows must span all 8 columns. Loading shows the existing t("Loading...") row; empty shows the existing emptyMessage. When the status filter yields no users, the same empty row shows. The Organizations cell handles the zero-org case with the dash above.
Responsive. The table is already inside overflow-x-auto (UsersTab.tsx:341); the new columns inherit horizontal scroll on narrow viewports. Chips wrap within the Organizations cell rather than widening the column unbounded.
Accessibility. The status filter Select has an associated label. The +{n} more chip is focusable and its tooltip content is available on focus. Status and org chips are not color-only (text labels present). Focus rings follow the base layer convention.
Dark mode. The existing table is styled with light-only utility classes (for example bg-white, text-gray-900); the new cells and pills match those same classes for visual consistency rather than introducing a partial dark-mode treatment. A full token-based dark-mode pass on this table is out of scope (see Follow-ups).
organizations array on every user row, containing that user's active organization memberships (each with id, name, displayName, and isActive: true), and an empty array for users with no active membership.totalUsers, totalPages) on GET /v1/plus/users is unchanged by adding the memberships include (counts match the same query without the include).status filter composes with search and organizationId: results satisfy all supplied filters simultaneously, and the counts reflect the combined filter./admin?tab=users, the table shows an Organizations column listing each user's active organization names; a user that belongs to one organization shows that one name.+{n} more chip, and the row height does not grow beyond a single-line table row.+{n} more chip reveals a tooltip containing the full list of the user's active organization names.sherpa-test (API).
organizations array per user and the correct active set for each seeded user.status=active, status=inactive, and status=all partition the seeded users correctly, and that totalUsers matches the filtered count.status combined with search and organizationId returns the intersection.sherpa-test (browser).
/admin?tab=users as an admin and assert the Organizations column is populated (not blank) for a user known to have memberships.Manual.
OrganizationMembersDialog, sherpa-portal/app/components/admin/OrganizationMembersDialog.tsx).listCustomers endpoint (sherpa-api/src/routes/userRoutes.ts:31); this ticket covers the CRM admin Users tab only.sherpa-api/src/models/User.ts:165). Confirm this matches the intended product meaning, or specify the intended rule if different./admin?tab=users table were captured for this draft. If a visual reference is needed for review, capture the current Users tab before implementation.Research task. Read-only. Find what exists in Sherpa codebase for admin users table feature. Write report to output_path.
Start: map repos + find admin users page.
{"command":"ls -la /Users/bakker/dev/gangkhar/ | head -50","description":"List sibling repos in root"}total 200 drwxr-xr-x 83 bakker staff 2656 Jun 2 11:26 . drwxr-xr-x 4 bakker staff 128 Apr 6 11:10 .. -rw-r--r--@ 1 bakker staff 30724 May 26 18:54 .DS_Store drwxr-xr-x@ 5 bakker staff 160 May 29 15:36 .claude drwxr-xr-x@ 3 bakker staff 96 May 27 19:59 .wrangler drwx------@ 13 bakker staff 416 May 27 19:57 Nordbank drwx------@ 63 bakker staff 2016 May 28 00:23 Velora drwx------@ 27 bakker staff 864 May 27 21:27 Voltkrone drwxr-xr-x@ 7 bakker staff 224 May 12 13:40 ado-migration -rw-r--r--@ 1 bakker staff 8324 May 29 15:33 anv-nordic-demo-summary.md drwxr-xr-x 13 bakker staff 416 May 12 15:08 jira-sync-worker drwxr-xr-x@ 22 bakker staff 704 Apr 6 13:01 lmt drwxr-xr-x@ 33 bakker staff 1056 May 27 13:25 node_modules -rw-r--r--@ 1 bakker staff 51023 May 27 13:25 package-lock.json -rw-r--r--@ 1 bakker staff 57 May 27 13:25 package.json drwxr-xr-x@ 44 bakker staff 1408 May 27 22:48 sherpa-api drwxr-xr-x@ 45 bakker staff 1440 Jun 1 21:16 sherpa-api-api-456-outlet-catalog-per-product-outle drwxr-xr-x@ 45 bakker staff 1440 Jun 1 22:53 sherpa-api-api-457-custom-additional-fields-on-endo drwxr-xr-x@ 45 bakker staff 1440 Jun 1 22:44 sherpa-api-api-458-credit-check-compliance-integrat drwxr-xr-x@ 45 bakker staff 1440 Jun 1 17:38 sherpa-api-api-459-scheduled-pre-payout-sanction-sc drwxr-xr-x@ 45 bakker staff 1440 Jun 1 09:02 sherpa-api-api-460-timed-delayed-emails-for-cooling drwxr-xr-x@ 45 bakker staff 1440 Jun 1 08:40 sherpa-api-api-461-no-login-public-withdrawal-cance drwxr-xr-x@ 45 bakker staff 1440 Jun 1 04:31 sherpa-api-api-462-nordic-bank-direct-debit-autogir drwxr-xr-x@ 45 bakker staff 1440 Jun 1 03:01 sherpa-api-api-463-recurring-card-payments-subscrip drwxr-xr-x@ 45 bakker staff 1440 May 31 22:23 sherpa-api-api-464-multiple-payment-providers-per-p drwxr-xr-x@ 45 bakker staff 1440 May 31 21:10 sherpa-api-api-465-per-outlet-claim-rules drwxr-xr-x@ 45 bakker staff 1440 May 31 14:19 sherpa-api-api-466-passkey-webauthn-login drwxr-xr-x@ 43 bakker staff 1376 May 20 16:24 sherpa-api-auto-detect-style drwxr-xr-x@ 43 bakker staff 1376 May 23 11:08 sherpa-api-customer-support-module-rgebm drwxr-xr-x@ 45 bakker staff 1440 Jun 1 14:13 sherpa-api-portal-263-per-outlet-documents-template drwxr-xr-x@ 43 bakker staff 1376 Jun 1 15:50 sherpa-api-telepeaje-org drwxr-xr-x@ 19 bakker staff 608 Jun 2 12:53 sherpa-ascent drwxr-xr-x@ 31 bakker staff 992 May 26 11:26 sherpa-embed drwxr-xr-x@ 31 bakker staff 992 Jun 1 23:16 sherpa-embed-api-456-outlet-catalog-per-product-outle drwxr-xr-x@ 30 bakker staff 960 May 31 15:18 sherpa-embed-api-457-custom-additional-fields-on-endo drwxr-xr-x@ 30 bakker staff 960 May 31 14:20 sherpa-embed-api-458-credit-check-compliance-integrat drwxr-xr-x@ 30 bakker staff 960 Jun 1 07:20 sherpa-embed-api-459-scheduled-pre-payout-sanction-sc drwxr-xr-x@ 30 bakker staff 960 Jun 1 09:02 sherpa-embed-api-460-timed-delayed-emails-for-cooling drwxr-xr-x@ 30 bakker staff 960 Jun 1 08:39 sherpa-embed-api-461-no-login-public-withdrawal-cance drwxr-xr-x@ 30 bakker staff 960 Jun 1 04:31 sherpa-embed-api-462-nordic-bank-direct-debit-autogir drwxr-xr-x@ 31 bakker staff 992 Jun 1 02:56 sherpa-embed-api-463-recurring-card-payments-subscrip drwxr-xr-x@ 30 bakker staff 960 May 31 22:23 sherpa-embed-api-464-multiple-payment-providers-per-p drwxr-xr-x@ 30 bakker staff 960 May 31 21:10 sherpa-embed-api-465-per-outlet-claim-rules drwxr-xr-x@ 30 bakker staff 960 May 31 14:19 sherpa-embed-api-466-passkey-webauthn-login drwxr-xr-x@ 27 bakker staff 864 May 20 16:44 sherpa-embed-auto-detect-style drwxr-xr-x@ 30 bakker staff 960 Jun 1 14:13 sherpa-embed-portal-263-per-outlet-documents-template drwxr-xr-x@ 28 bakker staff 896 Jun 1 15:50 sherpa-embed-telepeaje-org drwxr-xr-x@ 6 bakker staff 192 Apr 6 14:20 sherpa-l3-data drwxr-xr-x@ 34 bakker staff 1088 May 20 15:56 sherpa-lens
{"pattern":"sherpa-portal/**/admin/**/*.{ts,tsx}"}No files found
{"pattern":"tab=users|tab===.users|'users'|\"users\"","path":"/Users/bakker/dev/gangkhar/sherpa-portal","output_mode":"files_with_matches","glob":"**/*.tsx","head_limit":30}Found 4 files /Users/bakker/dev/gangkhar/sherpa-portal/app/components/AppLayout.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/admin/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/embed-customer-portal/templates/wayni/index.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/embed-customer-portal/templates/wayni/Beneficiarios.tsx
Found admin page. Read it + map admin dir + portal structure.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/admin/page.tsx"}1 "use client";
2
3 import React from "react";
4
5 import { useSearchParams } from "next/navigation";
6
7 import { useTranslation } from "@gangkharai/lmt/client";
8
9 import AuditLogsTab from "@/app/components/admin/AuditLogsTab";
10 import DataModelTab from "@/app/components/admin/DataModelTab";
11 import InsuranceTypesTab from "@/app/components/admin/InsuranceTypesTab";
12 import IntegrationsTab from "@/app/components/admin/IntegrationsTab";
13 import ModulesTab from "@/app/components/admin/ModulesTab";
14 import OrganizationsTab from "@/app/components/admin/OrganizationsTab";
15 import PartnersTab from "@/app/components/admin/PartnersTab";
16 import UsersTab from "@/app/components/admin/UsersTab";
17 import { AdminOnlyRoute } from "@/app/components/AdminOnlyRoute";
18 import AppLayout from "@/app/components/AppLayout";
19
20 type AdminTab =
21 | "users"
22 | "organizations"
23 | "modules"
24 | "partners"
25 | "integrations"
26 | "insurance-types"
27 | "data-model"
28 | "audit-logs";
29
30 const AdminPage: React.FC = () => {
31 const searchParams = useSearchParams();
32 const activeTab = (searchParams.get("tab") as AdminTab | null) || "organizations";
33
34 return (
35 <AdminOnlyRoute>
36 <AppLayout>
37 {activeTab === "organizations" && <OrganizationsTab />}
38 {activeTab === "users" && <UsersTab />}
39 {activeTab === "modules" && <ModulesTab />}
40 {activeTab === "partners" && <PartnersTab />}
41 {activeTab === "integrations" && <IntegrationsTab />}
42 {activeTab === "insurance-types" && <InsuranceTypesTab />}
43 {activeTab === "data-model" && <DataModelTab />}
44 {activeTab === "audit-logs" && <AuditLogsTab />}
45 </AppLayout>
46 </AdminOnlyRoute>
47 );
48 };
49
50 export default AdminPage;
51 {"command":"find /Users/bakker/dev/gangkhar/sherpa-portal/app/admin -type f | head -60; echo \"---ADMIN COMPONENTS---\"; find /Users/bakker/dev/gangkhar/sherpa-portal/app/components -type f -iname '*user*' -o -type f -iname '*admin*' | head -40","description":"List admin dir + user/admin components"}/Users/bakker/dev/gangkhar/sherpa-portal/app/admin/integrations/[id]/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/admin/page.tsx ---ADMIN COMPONENTS--- /Users/bakker/dev/gangkhar/sherpa-portal/app/components/UserTypeRoute.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/UserCreateForm.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/UsersTab.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/UserCreatedSuccess.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/components/users/UserDetail.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/components/useUserData.tsx /Users/bakker/dev/gangkhar/sherpa-portal/app/components/AdminOnlyRoute.tsx
Core file = UsersTab.tsx. Read it + sibling OrganizationsTab (for filter/active pattern) + useUserData hook.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/UsersTab.tsx"}1 "use client";
2
3 import React, { useState, useEffect, useCallback } from "react";
4
5 import { useRouter } from "next/navigation";
6
7 import { ArrowUpDown, Search, X, PlusCircle, Trash2 } from "lucide-react";
8
9 import { useTranslation } from "@gangkharai/lmt/client";
10
11 import Pagination from "@/app/components/Pagination";
12 import { useDebounce } from "@/app/hooks/useDebounce";
13 import api from "@/app/src/utils/api";
14 import { toTitleCase } from "@/app/utils/stringUtils";
15 import { UserData } from "@/app/types/user";
16 import {
17 Select,
18 SelectContent,
19 SelectItem,
20 SelectTrigger,
21 SelectValue,
22 } from "@/app/components/ui/select";
23 import { Organization } from "@/app/types/admin";
24 import { Button } from "@/app/components/ui/button";
25 import UserCreateForm, { UserCreatedData } from "./UserCreateForm";
26 import UserCreatedSuccess from "./UserCreatedSuccess";
27 import {
28 Dialog,
29 DialogContent,
30 DialogDescription,
31 DialogFooter,
32 DialogHeader,
33 DialogTitle,
34 } from "@/app/components/ui/dialog";
35 import { showSuccessToast, showErrorToast } from "@/app/components/Toast";
36 import { useOrganization } from "@/app/components/OrganizationProvider";
37 import useAuthStore from "@/app/src/store/authStore";
38 import { Can } from "@/app/components/Can";
39 import { PERMISSIONS } from "@/app/types/permissions";
40
41 interface UserListResponse {
42 status: string;
43 data: {
44 users: UserData[];
45 currentPage: number;
46 totalPages: number;
47 totalUsers: number;
48 sortField: string;
49 sortOrder: string;
50 };
51 }
52
53 interface SortConfig {
54 field: string;
55 order: "asc" | "desc";
56 }
57
58 interface UsersTabProps {
59 userType?: "crm" | "customer";
60 title?: string;
61 description?: string;
62 searchPlaceholder?: string;
63 emptyMessage?: string;
64 showOrganizationFilter?: boolean;
65 showCreateButton?: boolean;
66 apiBasePath?: string;
67 routeBasePath?: string;
68 }
69
70 const getRoleColorClass = (role: string): string => {
71 switch (role) {
72 case "super_admin":
73 return "bg-red-100 text-red-800";
74 case "owner":
75 return "bg-purple-100 text-purple-800";
76 case "admin":
77 return "bg-blue-100 text-blue-800";
78 case "contributor":
79 return "bg-green-100 text-green-800";
80 case "viewer":
81 return "bg-gray-100 text-gray-800";
82 default:
83 return "bg-gray-100 text-gray-800";
84 }
85 };
86
87 const UsersTab: React.FC<UsersTabProps> = ({
88 userType = "crm",
89 title = "CRM Users",
90 description = "A list of all CRM users in the system",
91 searchPlaceholder = "Search CRM users by name or email...",
92 emptyMessage = "No CRM users found",
93 showOrganizationFilter = true,
94 showCreateButton = true,
95 apiBasePath = "/users",
96 routeBasePath = "/users",
97 }) => {
98 const t = useTranslation();
99 const router = useRouter();
100 const { organization } = useOrganization();
101 const drillDownOrgId = useAuthStore((s) => s.drillDownOrgId);
102 const [users, setUsers] = useState<UserData[]>([]);
103 const [loading, setLoading] = useState(true);
104 const [error, setError] = useState<string | React.ReactNode>("");
105 const [currentPage, setCurrentPage] = useState(1);
106 const [totalPages, setTotalPages] = useState(1);
107 const [sortConfig, setSortConfig] = useState<SortConfig>({
108 field: "lastName",
109 order: "desc",
110 });
111 const [searchTerm, setSearchTerm] = useState("");
112 const debouncedSearchTerm = useDebounce(searchTerm, 300);
113 const [organizations, setOrganizations] = useState<Organization[]>([]);
114 const [organizationFilter, setOrganizationFilter] = useState("all");
115 const [isCreatingUser, setIsCreatingUser] = useState(false);
116 const [createdUserData, setCreatedUserData] = useState<UserCreatedData | null>(null);
117 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
118 const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
119 const [isDeleting, setIsDeleting] = useState(false);
120
121 const getOrganizationName = (organizationId: string | undefined): string => {
122 if (!organizationId) return "";
123 const organization = organizations.find((org) => org.id === organizationId);
124 return organization?.displayName || "";
125 };
126
127 const fetchUsers = useCallback(async () => {
128 try {
129 setLoading(true);
130 setError("");
131
132 const effectiveOrgFilter = showOrganizationFilter
133 ? organizationFilter
134 : drillDownOrgId ?? "all";
135
136 const response = await api.get<UserListResponse>(
137 `${apiBasePath}?page=${currentPage}&limit=10&sortField=${sortConfig.field}&sortOrder=${sortConfig.order}&userType=${userType}${debouncedSearchTerm ? `&search=${encodeURIComponent(debouncedSearchTerm)}` : ""}${effectiveOrgFilter !== "all" ? `&organizationId=${encodeURIComponent(effectiveOrgFilter)}` : ""}`
138 );
139
140 // Filter out deleted users
141 const activeUsers = response.data.data.users.filter((user) => !user.deleted);
142 setUsers(activeUsers);
143 setTotalPages(response.data.data.totalPages);
144 } catch (error) {
145 setError(t("Failed to fetch users"));
146 console.error("Error fetching users:", error);
147 } finally {
148 setLoading(false);
149 }
150 }, [
151 currentPage,
152 sortConfig.field,
153 sortConfig.order,
154 debouncedSearchTerm,
155 organizationFilter,
156 userType,
157 showOrganizationFilter,
158 organization?.id,
159 apiBasePath,
160 drillDownOrgId,
161 ]);
162
163 useEffect(() => {
164 fetchUsers();
165 }, [fetchUsers]);
166
167 useEffect(() => {
168 const loadOrganizations = async () => {
169 try {
170 const res = await api.get<{ status: string; data?: Organization[] }>("/organizations");
171 setOrganizations(res.data.data || []);
172 } catch {}
173 };
174 void loadOrganizations();
175 }, []);
176
177 const handleSort = (field: string) => {
178 setSortConfig((prevConfig) => ({
179 field,
180 order: prevConfig.field === field && prevConfig.order === "asc" ? "desc" : "asc",
181 }));
182 setCurrentPage(1);
183 };
184
185 const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
186 setSearchTerm(event.target.value);
187 setCurrentPage(1);
188 };
189
190 const clearSearch = () => {
191 setSearchTerm("");
192 setCurrentPage(1);
193 };
194
195 const handleCreateUser = () => {
196 setIsCreatingUser(true);
197 setCreatedUserData(null);
198 };
199
200 const handleCancelCreate = () => {
201 setIsCreatingUser(false);
202 setCreatedUserData(null);
203 };
204
205 const handleUserCreated = (data: UserCreatedData) => {
206 setIsCreatingUser(false);
207 setCreatedUserData(data);
208 };
209
210 const handleBackToUsers = () => {
211 setCreatedUserData(null);
212 fetchUsers(); // Refresh the users list
213 };
214
215 const handleDeleteClick = (e: React.MouseEvent, user: UserData) => {
216 e.stopPropagation(); // Prevent row click navigation
217 setUserToDelete(user);
218 setDeleteConfirmOpen(true);
219 };
220
221 const handleConfirmDelete = async () => {
222 if (!userToDelete) return;
223
224 setIsDeleting(true);
225 try {
226 await api.delete(`${apiBasePath}/${userToDelete.id}`);
227 showSuccessToast(t("User deleted successfully"));
228 setDeleteConfirmOpen(false);
229 setUserToDelete(null);
230 fetchUsers(); // Refresh the list
231 } catch (error) {
232 console.error("Error deleting user:", error);
233 showErrorToast(t("Failed to delete user"));
234 } finally {
235 se{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/useUserData.tsx"}1 import { useEffect, useState } from "react";
2
3 import { useAuth } from "./AuthProvider"; // Adjust the import path as needed
4
5 // useUserData hook - processed for translation
6 export function useUserData() {
7 const { user, loading } = useAuth();
8 const [error, setError] = useState<React.ReactNode>("");
9
10 useEffect(() => {
11 if (!loading && !user) {
12 setError(<>No user data available. Please log in again.</>);
13 } else {
14 setError("");
15 }
16 }, [user, loading]);
17
18 return { user, isLoading: loading, error };
19 }
20 Key finding: Organization column EXISTS (line 377, 433-435) but uses singular user.organizationId. That's the bug. Table shows "2FA Status" not active/inactive. Filter pattern exists (Select, line 315-338).
Now: types + UserDetail (may show multi-org) + API endpoint. Batch.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/types/user.ts"}1 // types.ts
2
3 export interface UserOrganizationMembership {
4 id: string;
5 name: string;
6 displayName?: string;
7 UserOrganization: {
8 role: string;
9 isActive: boolean;
10 createdAt: string;
11 };
12 }
13
14 export interface UserData {
15 id: string;
16 firstName: string;
17 lastName: string;
18 companyName?: string;
19 email: string;
20 phoneNumber: string;
21 country: string;
22 region: string;
23 address: string;
24 city: string;
25 postalCode: string;
26 role: string;
27 userType: string;
28 twoFactorMethod?: string;
29 twoFactorEnabled: boolean;
30 createdAt: string;
31 updatedAt: string;
32 deleted: boolean;
33 organizationId?: string;
34 organizationName?: string;
35 organization?: {
36 id: string;
37 name: string;
38 displayName?: string;
39 };
40 organizations?: UserOrganizationMembership[];
41 }
42
43 export interface AuthContextType {
44 user: UserData | null;
45 loading: boolean;
46 login: (accessToken: string, refreshToken: string, userData?: Partial<UserData>) => Promise<void>;
47 logout: () => void;
48 refreshToken: () => Promise<string | null>;
49 }
50
51 export type EditableSection = "personalInfo" | "contactInfo" | "address" | "password";
52
53 export interface PasswordRequirement {
54 regex: RegExp;
55 text: string;
56 }
57
58 export const passwordRequirements: PasswordRequirement[] = [
59 { regex: /.{8,}/, text: "At least 8 characters long" },
60 { regex: /[A-Z]/, text: "At least one uppercase letter" },
61 { regex: /[a-z]/, text: "At least one lowercase letter" },
62 { regex: /[0-9]/, text: "At least one number" },
63 { regex: /[@$!%*?&]/, text: "At least one special character (@$!%*?&)" },
64 ];
65
66 export interface PersonalInfoSectionProps {
67 userData: UserData;
68 onUpdate: (data: Partial<UserData>) => Promise<void>;
69 isLoading: boolean;
70 }
71
72 export interface ContactInfoSectionProps {
73 userData: UserData;
74 onUpdate: (data: Partial<UserData>) => Promise<void>;
75 isLoading: boolean;
76 }
77
78 export interface AddressSectionProps {
79 userData: UserData;
80 onUpdate: (data: Partial<UserData>) => Promise<void>;
81 isLoading: boolean;
82 }
83
84 export interface PasswordChangeSectionProps {
85 userId: string;
86 onSuccess: () => void;
87 onError: (message: string) => void;
88 }
89
90 export interface FormData {
91 firstName: string;
92 lastName: string;
93 companyName?: string;
94 email: string;
95 password: string;
96 phoneNumber: string;
97 country: string;
98 region: string;
99 address: string;
100 city: string;
101 postalCode: string;
102 }
103
104 export type LogoutSectionProps = Record<string, never>;
105 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/types/admin.ts"}1 export type DisableClaimAiAssessmentMode = "none" | "customer" | "all";
2
3 export type OrganizationType = "insurer" | "broker" | "affinity";
4
5 export interface Organization {
6 id: string;
7 name: string;
8 displayName?: string;
9 description?: string;
10 portalDomain: string;
11 portalUrl: string;
12 apiUrl?: string;
13 portalType: "customer" | "crm";
14 portalMode: "standalone" | "embedded";
15 active: boolean;
16 timezone?: string;
17 settings?: Record<string, unknown>;
18 disableClaimAiAssessment?: DisableClaimAiAssessmentMode;
19 disableClaimMessages?: boolean;
20 organizationType: OrganizationType;
21 parentOrganizationId: string | null;
22 disablePolicyMessages?: boolean;
23 createdAt: string;
24 updatedAt: string;
25 }
26
27 import { RoleLevel } from './permissions';
28
29 export type CreateOrganization = Omit<
30 Organization,
31 "id" | "createdAt" | "updatedAt" | "organizationType" | "parentOrganizationId"
32 > & {
33 organizationType?: OrganizationType;
34 parentOrganizationId?: string | null;
35 };
36 export type UpdateOrganization = Partial<CreateOrganization>;
37
38 export type ModuleCategory = "core" | "business" | "admin" | "analytics" | "integration";
39
40 export interface ModuleDef {
41 id: number;
42 key: string;
43 name: string;
44 description?: string;
45 icon?: string;
46 route: string;
47 category: ModuleCategory;
48 sortOrder?: number;
49 requiredRole?: RoleLevel;
50 active: boolean;
51 }
52
53 export type CreateModuleDef = Omit<ModuleDef, "id">;
54 export type UpdateModuleDef = Partial<CreateModuleDef>;
55
56 export interface Partner {
57 id: string;
58 name: string;
59 createdAt: string;
60 updatedAt: string;
61 }
62
63 export type CreatePartner = Omit<Partner, "id" | "createdAt" | "updatedAt">;
64 export type UpdatePartner = Partial<CreatePartner>;
65
66 export type PartnerEnvironmentType = "test" | "staging" | "production";
67
68 export interface PartnerEnvironment {
69 id: string;
70 partnerId: string;
71 environment: PartnerEnvironmentType;
72 baseUrl: string;
73 authEndpoint?: string;
74 tokenEndpoint?: string;
75 }
76
77 export type CreatePartnerEnvironment = Omit<PartnerEnvironment, "id">;
78 export type UpdatePartnerEnvironment = Partial<CreatePartnerEnvironment>;
79
80 export type AuthType =
81 | "client_credentials"
82 | "username_password"
83 | "api_key"
84 | "basic_auth"
85 | "oauth2_code"
86 | "custom";
87
88 export type TokenType = "bearer" | "basic" | "custom";
89
90 export interface PartnerAuthConfig {
91 id: string;
92 partnerEnvironmentId: string;
93 authType: AuthType;
94 tokenType: TokenType;
95 authorizationHeaderFormat?: string;
96 encryptedCredentials?: string;
97 active: boolean;
98 }
99
100 export interface CreatePartnerAuthConfig {
101 partnerEnvironmentId: string;
102 authType:
103 | "client_credentials"
104 | "username_password"
105 | "api_key"
106 | "basic_auth"
107 | "oauth2_code"
108 | "custom";
109 tokenType: "bearer" | "basic" | "custom";
110 authorizationHeaderFormat?: string;
111 active: boolean;
112 // Credential fields (based on authType)
113 username?: string;
114 password?: string;
115 clientId?: string;
116 clientSecret?: string;
117 apiKey?: string;
118 }
119
120 export interface UpdatePartnerAuthConfig {
121 authType?:
122 | "client_credentials"
123 | "username_password"
124 | "api_key"
125 | "basic_auth"
126 | "oauth2_code"
127 | "custom";
128 tokenType?: "bearer" | "basic" | "custom";
129 authorizationHeaderFormat?: string;
130 active?: boolean;
131 // Credential fields (only sent if updating)
132 username?: string;
133 password?: string;
134 clientId?: string;
135 clientSecret?: string;
136 apiKey?: string;
137 }
138
139 export interface OrganizationModuleAssignment {
140 id: number;
141 organizationId: string;
142 moduleId: number;
143 active: boolean;
144 settings?: Record<string, unknown>;
145 usePartnerApi?: boolean;
146 lastAccessed?: string;
147 }
148
149 export type CreateOrganizationModuleAssignment = Omit<OrganizationModuleAssignment, "id">;
150 export type UpdateOrganizationModuleAssignment = Partial<CreateOrganizationModuleAssignment>;
151
152 export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
153
154 export interface ModuleEndpoint {
155 id: number;
156 moduleId: number;
157 method: HttpMethod;
158 urlPattern: string;
159 controller?: string;
160 apiVersion?: string;
161 functionName?: string;
162 description?: string;
163 }
164
165 export type CreateModuleEndpoint = Omit<ModuleEndpoint, "id">;
166 export type UpdateModuleEndpoint = Partial<CreateModuleEndpoint>;
167
168 export interface PartnerEndpointMapping {
169 id: number;
170 partnerId: string;
171 moduleEndpointId: number;
172 urlPattern?: string;
173 active?: boolean;
174 executionOrder?: number;
175 excludeFromAutoLoading?: boolean;
176 httpMethod?: HttpMethod;
177 urlTemplate?: string;
178 requestBodyTemplate?: string | null;
179 responseMapping?: string | null;
180 dependsOnEndpoint?: number | null;
181 parameterTransformations?: string | null;
182 }
183
184 export type CreatePartnerEndpointMapping = Omit<PartnerEndpointMapping, "id">;
185 export type UpdatePartnerEndpointMapping = Partial<CreatePartnerEndpointMapping>;
186
187 export interface PartnerFieldMapping {
188 id: number;
189 partnerEndpointMappingId: number;
190 partnerFieldPath: string;
191 dataModelFieldId?: number;
192 customFieldName?: string;
193 transformationFunction?: string;
194 isRequired?: boolean;
195 defaultValue?: string;
196 enumMapping?: Record<string, string> | null;
197 direction?: "request" | "response";
198 }
199
200 export type CreatePartnerFieldMapping = Omit<PartnerFieldMapping, "id">;
201 export type UpdatePartnerFieldMapping = Partial<CreatePartnerFieldMapping>;
202
203 export interface ProductModulePartner {
204 id: number;
205 productId: number;
206 moduleId: number;
207 partnerId: string;
208 module?: ModuleDef;
209 partner?: Partner;
210 createdAt?: string;
211 updatedAt?: string;
212 }
213
214 export type CreateProductModulePartner = Omit<
215 ProductModulePartner,
216 "id" | "module" | "partner" | "createdAt" | "updatedAt"
217 >;
218 export type UpdateProductModulePartner = Partial<Pick<ProductModulePartner, "partnerId">>;
219
220 export interface PagedResponse<T> {
221 status: "success" | "error";
222 data?: {
223 items: T[];
224 total: number;
225 page: number;
226 pageSize: number;
227 };
228 message?: string;
229 }
230
231 export interface ListResponse<T> {
232 status: "success" | "error";
233 data?: T[];
234 message?: string;
235 }
236
237 // ─── External integrations (outbound third-party API framework) ──────────────
238
239 export type IntegrationCapability =
240 | "sanction_screening"
241 | "pep"
242 | "kyc"
243 | "adverse_media"
244 | "fraud_detection";
245
246 // Operations a capability supports. Today every compliance capability has a
247 // single 'check' operation; widen this when a capability gains more.
248 export const CAPABILITY_OPERATIONS: Record<IntegrationCapability, readonly string[]> = {
249 sanction_screening: ["check"],
250 pep: ["check"],
251 kyc: ["check"],
252 adverse_media: ["check"],
253 fraud_detection: ["check"],
254 };
255 export type IntegrationKind = "generic_rest";
256 export type IntegrationEnvironment = "test" | "staging" | "production";
257
258 export type IntegrationInvocationMode = "sync" | "async";
259
260 // Sensible per-capability defaults. Slow review-style providers default to
261 // async so the customer isn't blocked behind document review.
262 export const CAPABILITY_DEFAULT_INVOCATION_MODE: Record<IntegrationCapability, IntegrationInvocationMode> = {
263 sanction_{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/users/UserDetail.tsx"}1 "use client";
2
3 import React, { useState, useEffect } from "react";
4
5 import { useRouter } from "next/navigation";
6 import { useLMT } from "@/lib/lmt/config";
7
8 import { ArrowLeft, History, Shield, Users, Building2, FileText, ExternalLink } from "lucide-react";
9
10 import { useAuth } from "../../components/AuthProvider";
11 import AppLayout from "@/app/components/AppLayout";
12 import AddressSection from "../../components/profile/AddressSection";
13 import ContactInfoSection from "../../components/profile/ContactInfoSection";
14 import PasswordChangeSection from "../../components/profile/PasswordChangeSection";
15 import PersonalInfoSection from "../../components/profile/PersonalInfoSection";
16 import { showSuccessToast, showErrorToast } from "../../components/Toast";
17 import api from "../../src/utils/api";
18 import { usePolicyStatuses } from "../../src/hooks/usePolicyStatuses";
19 import { PERMISSIONS, ROLE_LEVELS } from "../../types/permissions";
20 import { toTitleCase } from "../../utils/stringUtils";
21 import { UserData } from "../../types/user";
22
23 import {
24 Select,
25 SelectContent,
26 SelectItem,
27 SelectTrigger,
28 SelectValue,
29 } from "@/app/components/ui/select";
30 import { Switch } from "@/app/components/ui/switch";
31 import { cn } from "@/app/lib/utils";
32
33 const isHighPrivilegeRole = (role: string): boolean => {
34 return role === "super_admin" || role === "owner";
35 };
36
37 interface UserDetailProps {
38 params: Promise<{
39 id: string;
40 }>;
41 apiBasePath?: string;
42 routeBasePath?: string;
43 }
44
45 interface PolicyOrder {
46 id: number;
47 policyId: string;
48 policyNumber: string;
49 status: string;
50 productName: string;
51 inceptionDate: string;
52 expiryDate: string;
53 product?: {
54 productTitle: string;
55 insuranceTypeTitle: string;
56 };
57 premiumWithTax?: string;
58 currency?: string;
59 }
60
61 const UserDetail: React.FC<UserDetailProps> = ({ params, apiBasePath = "/users", routeBasePath = "/users" }) => {
62 const router = useRouter();
63 const { user: currentUser } = useAuth();
64 const { id } = React.use(params);
65 const { t } = useLMT();
66 const { byCode, labelOf, colorOf } = usePolicyStatuses();
67 const [userData, setUserData] = useState<UserData | null>(null);
68 const [error, setError] = useState<React.ReactNode>(null);
69 const [isLoading, setIsLoading] = useState(false);
70 const [policies, setPolicies] = useState<PolicyOrder[]>([]);
71 const [policiesLoading, setPoliciesLoading] = useState(true);
72 const [accessDenied, setAccessDenied] = useState(false);
73 const [effectivePermissions, setEffectivePermissions] = useState<string[]>([]);
74
75 useEffect(() => {
76 const fetchPermissions = async () => {
77 if (currentUser) {
78 try {
79 const response = await api.get("/auth/me/permissions");
80 setEffectivePermissions(response.data.data.permissions || []);
81 } catch {
82 setEffectivePermissions([]);
83 }
84 }
85 };
86 fetchPermissions();
87 }, [currentUser]);
88
89 useEffect(() => {
90 const fetchUser = async () => {
91 try {
92 const response = await api.get(`${apiBasePath}/${id}`);
93 const data = response.data.data;
94 setUserData(data.user ?? data);
95 } catch (error: unknown) {
96 console.error("Error fetching user:", error);
97 const err = error as { response?: { data?: { error?: { message?: string } } } };
98 if (err?.response?.data?.error?.message === "Access denied") {
99 setAccessDenied(true);
100 } else {
101 setError(t("Failed to fetch user details"));
102 }
103 }
104 };
105
106 fetchUser();
107 }, [id]);
108
109 useEffect(() => {
110 const fetchPolicies = async () => {
111 if (!userData || !currentUser || currentUser.userType !== "crm") {
112 setPoliciesLoading(false);
113 return;
114 }
115
116 try {
117 setPoliciesLoading(true);
118 const response = await api.get(`/policy-orders?userId=${id}&limit=10`);
119
120 if (response.data.status === "success") {
121 setPolicies(response.data.data.policyOrders || []);
122 }
123 } catch (error) {
124 console.error("Error fetching policies:", error);
125 } finally {
126 setPoliciesLoading(false);
127 }
128 };
129
130 fetchPolicies();
131 }, [id, userData, currentUser]);
132
133 const handleUpdate = async (data: Partial<UserData>) => {
134 setIsLoading(true);
135 setError(null);
136
137 try {
138 const response = await api.put(`${apiBasePath}/${id}`, data);
139 const responseData = response.data.data;
140 setUserData(responseData.user ?? responseData);
141 showSuccessToast(t("User updated successfully"));
142 } catch (error) {
143 console.error("Error updating user:", error);
144 const errorMsg = error instanceof Error ? error.message : t("An unexpected error occurred");
145 setError(errorMsg);
146 showErrorToast(errorMsg);
147 } finally {
148 setIsLoading(false);
149 }
150 };
151
152 const canViewUser = () => {
153 if (!currentUser) return false;
154 if (currentUser.id === id) return true;
155 if (effectivePermissions.includes(PERMISSIONS.users.read.value)) return true;
156 return false;
157 };
158
159 if (accessDenied) {
160 return (
161 <AppLayout>
162 <div className="flex min-h-screen items-center justify-center">
163 <div className="text-center">
164 <h1 className="text-2xl font-bold text-gray-900">{t("Access Denied")}</h1>
165 <p className="mt-2 text-gray-600">{t("You do not have permission to view this page.")}</p>
166 </div>
167 </div>
168 </AppLayout>
169 );
170 }
171
172 if (!userData) {
173 return (
174 <AppLayout>
175 <div></div>
176 </AppLayout>
177 );
178 }
179
180 if (!canViewUser()) {
181 return (
182 <AppLayout>
183 <div className="flex min-h-screen items-center justify-center">
184 <div className="text-center">
185 <h1 className="text-2xl font-bold text-gray-900">{t("Access Denied")}</h1>
186 <p className="mt-2 text-gray-600">{t("You do not have permission to view this page.")}</p>
187 </div>
188 </div>
189 </AppLayout>
190 );
191 }
192
193 return (
194 <AppLayout>
195 <div className="mx-auto max-w-4xl py-6 sm:px-6 lg:px-8">
196 <div className="mb-6">
197 <button
198 onClick={() => router.back()}
199 className="flex cursor-pointer items-center text-blue-600 hover:text-blue-900"
200 >
201 <ArrowLeft className="mr-2 h-4 w-4" />
202 {userData?.userType === "customer" ? t("Back to Customers") : t("Back to Users")}
203 </button>
204 </div>
205
206 <h1 className="mb-6 text-3xl font-bold text-gray-900">
207 {userData?.userType === "customer" ? t("Customer Details") : t("User Details")}
208 </h1>
209
210 {error && (
211 <div className="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">
212 {error}
213 </div>
214 )}
215
216 <PersonalInfoSection userData={userData} onUpdate={handleUpdate} isLoading={isLoading} />
217
218 <ContactInfoSection userData={userData} onUpdate={handleUpdate} isLoading={isLoading} />
219
220 <AddressSection userData={userData} onUpdate={handleUpdate} isLoading={isLoading} />
221
222 <PasswordChangeSection
223 userId={userData.id}
224 onSuccess={() => showSuccessToast(t("Password updated successfully"))}
225 onError={(eMajor finding: UserOrganizationMembership type exists (user.ts:3-12), UserData.organizations[] exists (line 40). UserDetail.tsx ALREADY renders multi-org memberships with active badges (lines 344-395). UsersTab list does NOT (only singular organizationId). User "active" = deleted flag in UserDetail (lines 300-320), and list filters out deleted (line 141).
Now sherpa-api side: /users list endpoint, model, what "active" means. Plus OrganizationsTab filter pattern.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-api && echo \"=== user route files ===\" && fd -t f -i 'user' src --max-results 40 2>/dev/null; echo \"=== userorg / membership ===\" && fd -t f -i 'userorg|membership|user-org' src --max-results 20 2>/dev/null","description":"Find user route + model files in sherpa-api"}=== user route files === src/controllers/userController.ts src/db/migrations/2025.07.20T00.00.09.create-user.ts src/db/migrations/2025.07.20T00.00.10.add-password-and-role-to-users.ts src/db/migrations/2025.07.20T00.00.12.add-refresh-token-to-users.ts src/db/migrations/2025.07.20T00.00.15.add-deleted-to-users.ts src/db/migrations/2025.07.20T00.00.18.add-2fa-fields-to-users.ts src/db/migrations/2025.07.20T00.00.19.add-reset-token-to-users.ts src/db/migrations/2025.07.20T00.00.20.add-address-and-phone-to-users.ts src/db/migrations/2025.07.20T00.00.24.product-unique-name-per-user.ts src/db/migrations/2025.07.20T00.01.08.add-user-id-to-clients.ts src/db/migrations/2025.07.20T00.01.14.add-azure-ad-fields-to-users.ts src/db/migrations/2025.07.20T00.01.16.add-organization-id-to-users.ts src/db/migrations/2025.08.20T12.48.23.seed-admin-users.ts src/db/migrations/2025.09.18T00.00.02.add-user-id-to-policy-orders.ts src/db/migrations/2025.09.19T00.00.02.populate-user-organizations.ts src/db/migrations/2025.10.11T00.00.04.add-user-organizations-for-customers.ts src/db/migrations/2025.10.14T19.30.00.fix-user-endpoint-url-pattern.ts src/db/migrations/2025.10.18T18.35.00.fix-user-endpoint-dependency.ts src/db/migrations/2025.10.19T18.58.00.fix-user-endpoint-url-pattern-param.ts src/db/migrations/2025.10.30T23.00.00.update-user-endpoints-to-plural.ts src/db/migrations/2025.10.30T23.01.00.update-user-endpoints-to-plural-fix.ts src/db/migrations/2025.11.01T00.01.00.set-user-endpoint-depends-on-policies-partner-data.ts src/db/migrations/2025.11.04T00.00.01.remove-users-delete-scope.ts src/db/migrations/2025.11.24T15.25.01.create-user-permissions.ts src/db/migrations/2025.12.03T00.00.02.change-user-role-enum.ts src/db/migrations/2025.12.03T00.00.03.simplify-user-permissions.ts src/db/migrations/2025.12.03T00.00.04.change-user-organization-role.ts src/db/migrations/2025.12.19T00.00.00.setup-rls-dashboard-user.ts src/db/migrations/2026.01.06T04.00.00.create-sherpa-app-user.ts src/db/migrations/2026.01.06T05.00.00.create-app-user.ts src/db/migrations/2026.01.14T00.00.01.seed-embedded-portal-test-user.ts src/db/migrations/2026.01.30T00.00.01.rename-userid-to-customerid.ts src/db/migrations/2026.01.30T00.00.03.remove-customers-from-users.ts src/db/migrations/2026.02.06T00.00.02.rename-user-endpoint-to-customer.ts src/db/migrations/2026.02.09T01.00.00.seed-e2e-test-user-main-crm-org.ts src/db/migrations/2026.04.02T00.00.01.migrate-user-id-integer-to-uuid.ts src/routes/plus/userRoutes.ts src/routes/userRoutes.ts src/services/azureAdUserService.ts src/services/userService.ts === userorg / membership === src/db/migrations/2025.09.19T00.00.01.create-user-organizations.ts src/db/migrations/2025.09.19T00.00.02.populate-user-organizations.ts src/db/migrations/2025.10.11T00.00.04.add-user-organizations-for-customers.ts src/db/migrations/2025.12.03T00.00.04.change-user-organization-role.ts src/db/migrations/2026.01.06T05.00.00.fix-user-organizations-rls-policy.ts src/models/UserOrganization.ts src/models/internal/_UserOrganization.ts src/schemas/typebox/auth/UserOrganization.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/OrganizationsTab.tsx"}1 "use client";
2
3 import React, { useCallback, useEffect, useMemo, useState } from "react";
4
5 import { Plus, Pencil, Trash2, Check, X, Users, Download, Upload } from "lucide-react";
6
7 import { useTranslation } from "@gangkharai/lmt/client";
8
9 import { Button } from "@/app/components/ui/button";
10 import {
11 Dialog,
12 DialogContent,
13 DialogFooter,
14 DialogHeader,
15 DialogTitle,
16 } from "@/app/components/ui/dialog";
17 import { Input } from "@/app/components/ui/input";
18 import { Textarea } from "@/app/components/ui/textarea";
19 import { Switch } from "@/app/components/ui/switch";
20 import {
21 Select,
22 SelectContent,
23 SelectItem,
24 SelectTrigger,
25 SelectValue,
26 } from "@/app/components/ui/select";
27 import api from "@/app/src/utils/api";
28 import useAuthStore from "@/app/src/store/authStore";
29 import {
30 Organization,
31 CreateOrganization,
32 UpdateOrganization,
33 ModuleDef,
34 OrganizationModuleAssignment,
35 CreateOrganizationModuleAssignment,
36 UpdateOrganizationModuleAssignment,
37 OrganizationType,
38 } from "@/app/types/admin";
39 import ConfirmDialog from "./ConfirmDialog";
40 import { OrganizationMembersDialog } from "./OrganizationMembersDialog";
41 import {
42 OrganizationImportDialog,
43 exportOrganization,
44 } from "./OrganizationImportDialog";
45
46 interface OrgResponse {
47 status: "success" | "error";
48 data?: Organization[];
49 message?: string;
50 }
51
52 interface ModulesResponse {
53 status: "success" | "error";
54 data?: ModuleDef[];
55 }
56
57 interface AssignmentsResponse {
58 status: "success" | "error";
59 data?: OrganizationModuleAssignment[];
60 }
61
62 const TIMEZONE_IDS = [
63 "UTC",
64 "Europe/London",
65 "Europe/Paris",
66 "Europe/Berlin",
67 "Europe/Amsterdam",
68 "Europe/Brussels",
69 "Europe/Zurich",
70 "Europe/Vienna",
71 "Europe/Stockholm",
72 "Europe/Oslo",
73 "Europe/Copenhagen",
74 "Europe/Helsinki",
75 "Europe/Madrid",
76 "Europe/Rome",
77 "Europe/Lisbon",
78 "Europe/Warsaw",
79 "Europe/Prague",
80 "Europe/Budapest",
81 "Europe/Bucharest",
82 "Europe/Athens",
83 "Europe/Istanbul",
84 "Europe/Moscow",
85 "Asia/Dubai",
86 "Asia/Kolkata",
87 "Asia/Bangkok",
88 "Asia/Singapore",
89 "Asia/Shanghai",
90 "Asia/Tokyo",
91 "Asia/Seoul",
92 "Asia/Hong_Kong",
93 "Asia/Jakarta",
94 "Australia/Sydney",
95 "Australia/Melbourne",
96 "Australia/Perth",
97 "Pacific/Auckland",
98 "America/New_York",
99 "America/Chicago",
100 "America/Denver",
101 "America/Los_Angeles",
102 "America/Anchorage",
103 "America/Toronto",
104 "America/Vancouver",
105 "America/Mexico_City",
106 "America/Sao_Paulo",
107 "America/Argentina/Buenos_Aires",
108 "America/Bogota",
109 "America/Lima",
110 "America/Santiago",
111 "Africa/Cairo",
112 "Africa/Lagos",
113 "Africa/Johannesburg",
114 "Africa/Nairobi",
115 ];
116
117 function getOffsetMinutes(tz: string): number {
118 if (tz === "UTC") return 0;
119 const now = new Date();
120 const utcStr = now.toLocaleString("en-US", { timeZone: "UTC" });
121 const tzStr = now.toLocaleString("en-US", { timeZone: tz });
122 return (new Date(tzStr).getTime() - new Date(utcStr).getTime()) / 60000;
123 }
124
125 function getUtcOffsetLabel(tz: string): string {
126 if (tz === "UTC") return "UTC+0";
127 const parts = new Intl.DateTimeFormat("en-US", {
128 timeZone: tz,
129 timeZoneName: "shortOffset",
130 }).formatToParts(new Date());
131 const offsetPart = parts.find((p) => p.type === "timeZoneName");
132 return offsetPart?.value?.replace("GMT", "UTC") || tz;
133 }
134
135 const TIMEZONES = TIMEZONE_IDS
136 .map((tz) => ({ tz, offset: getOffsetMinutes(tz), label: getUtcOffsetLabel(tz) }))
137 .sort((a, b) => a.offset - b.offset || a.tz.localeCompare(b.tz));
138
139 const GANGKHAR_ORG_ID = "3d8c2906-b8fd-4702-945b-f78e976c2189";
140
141 const emptyForm: CreateOrganization = {
142 name: "",
143 displayName: "",
144 description: "",
145 portalDomain: "",
146 portalUrl: "",
147 apiUrl: "",
148 portalType: "crm",
149 portalMode: "standalone",
150 active: true,
151 timezone: "UTC",
152 settings: {},
153 organizationType: "affinity",
154 parentOrganizationId: null,
155 };
156
157 const OrganizationsTab: React.FC = () => {
158 const t = useTranslation();
159 const currentOrganization = useAuthStore((s) => s.currentOrganization);
160 const setOrganizationScope = useAuthStore((s) => s.setOrganizationScope);
161 const isGangkharAdmin = currentOrganization?.id === GANGKHAR_ORG_ID;
162 const [items, setItems] = useState<Organization[]>([]);
163 const [loading, setLoading] = useState(false);
164 const [error, setError] = useState<string | React.ReactNode>("");
165 const [formOpen, setFormOpen] = useState(false);
166 const [editing, setEditing] = useState<Organization | null>(null);
167 const [form, setForm] = useState<CreateOrganization>(emptyForm);
168 const [deleteId, setDeleteId] = useState<string | null>(null);
169 const [manageModulesOrg, setManageModulesOrg] = useState<Organization | null>(null);
170 const [modules, setModules] = useState<ModuleDef[]>([]);
171 const [assignments, setAssignments] = useState<OrganizationModuleAssignment[]>([]);
172 const [membersDialogOrg, setMembersDialogOrg] = useState<Organization | null>(null);
173 const [importOpen, setImportOpen] = useState(false);
174
175 const load = useCallback(async () => {
176 try {
177 setLoading(true);
178 setError("");
179 const [orgRes, modRes] = await Promise.all([
180 api.get<OrgResponse>("/organizations"),
181 api.get<ModulesResponse>("/modules"),
182 ]);
183 setItems(orgRes.data.data || []);
184 setModules(modRes.data.data || []);
185 } catch {
186 setError(t("Failed to fetch organizations"));
187 } finally {
188 setLoading(false);
189 }
190 }, []);
191
192 useEffect(() => {
193 void load();
194 }, [load]);
195
196 const openCreate = () => {
197 setEditing(null);
198 setForm(emptyForm);
199 setFormOpen(true);
200 };
201
202 const openEdit = (org: Organization) => {
203 const f: CreateOrganization = {
204 name: org.name,
205 displayName: org.displayName || "",
206 description: org.description || "",
207 portalDomain: org.portalDomain,
208 portalUrl: org.portalUrl,
209 apiUrl: org.apiUrl || "",
210 portalType: org.portalType,
211 portalMode: org.portalMode,
212 active: org.active,
213 timezone: org.timezone || "UTC",
214 settings: org.settings || {},
215 organizationType: org.organizationType,
216 parentOrganizationId: org.parentOrganizationId,
217 };
218 setEditing(org);
219 setForm(f);
220 setFormOpen(true);
221 };
222
223 const submit = async () => {
224 try {
225 setLoading(true);
226 setError("");
227 const payload: CreateOrganization = isGangkharAdmin
228 ? form
229 : (() => {
230 const { organizationType: _t, parentOrganizationId: _p, ...rest } = form;
231 void _t;
232 void _p;
233 return rest;
234 })();
235 if (editing) {
236 await api.put(`/organizations/${editing.id}`, payload as UpdateOrganization);
237 } else {
238 await api.post("/organizations", payload);
239 }
240 setFormOpen(false);
241 setEditing(null);
242 setOrganizationScope(null);
243 await load();
244 } catch (err) {
245 const apiMessage = (err as { response?: { data?: { message?: string } } })?.response
246 ?.data?.message;
247 setError(apiMessage || t("Failed to save organization"));
248 } finally {
249 setLoading(false);
250 }
251 };
252
253 const confirmDelete = async () => {
254 iOrganizationsTab has Active/Inactive badge column (lines 420-428) via org.active green/red pattern. Good badge reference. No top status filter there though.
Now critical: sherpa-api list endpoint + user model + UserOrganization. Board=plus, so check plus/userRoutes too.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/controllers/userController.ts"}1 import type { FastifyRequest, FastifyReply } from 'fastify';
2 import { Op, where, fn, col, type FindOptions } from 'sequelize';
3 import type { Model } from 'sequelize';
4 import '@/types/fastify.js';
5
6 import { getRequired } from '../../lib.js';
7 import { StatusError } from '../error/error.js';
8 import { Organization } from '../models/Organization.js';
9 import { User } from '../models/User.js';
10 import { UserOrganization } from '../models/UserOrganization.js';
11 import { UserPermission } from '../models/UserPermission.js';
12 import azureAdUserService from '@/services/azureAdUserService.js';
13 import { getOrganizationCrmUrl } from '@/utils/organizationUtils.js';
14 import { getUserEffectivePermissions } from '@/plugins/rbac.js';
15 import { PERMISSIONS, getAllPermissionDefs } from '@/types/permissions.js';
16
17 const AZURE_TENANT_ID = getRequired('AZURE_TENANT_ID');
18
19 interface UserParams {
20 id: string;
21 }
22
23 interface UsersQuery {
24 page?: string;
25 limit?: string;
26 sortField?: string;
27 sortOrder?: string;
28 search?: string;
29 role?: string;
30 userType?: string;
31 organizationId?: string;
32 }
33
34 interface UpdateUserBody {
35 firstName?: string;
36 lastName?: string;
37 email?: string;
38 role?: string;
39 phoneNumber?: string;
40 companyName?: string;
41 country?: string;
42 region?: string;
43 address?: string;
44 city?: string;
45 postalCode?: string;
46 deleted?: boolean;
47 organizationId?: string;
48 }
49
50 interface CreateUserBody {
51 firstName: string;
52 lastName: string;
53 email: string;
54 role?: string;
55 userType?: string;
56 phoneNumber?: string;
57 companyName?: string;
58 country?: string;
59 region?: string;
60 address?: string;
61 city?: string;
62 postalCode?: string;
63 organizationId: string;
64 }
65
66 interface UserWhereClause {
67 deleted: boolean;
68 [Op.or]?: Array<ReturnType<typeof where>>;
69 userType?: string;
70 [key: string]: unknown;
71 }
72
73 export const getAllUsers = async (
74 request: FastifyRequest<{ Querystring: UsersQuery }>,
75 _reply: FastifyReply
76 ) => {
77 void _reply;
78 const {
79 page,
80 limit,
81 sortField,
82 sortOrder,
83 search,
84 userType,
85 organizationId: queryOrganizationId,
86 } = request.query;
87 const requestingUserType = request.userAuth?.userType;
88 const requestingUserOrgId = request.userAuth?.organizationId;
89
90 const userPermissions = request.userAuth?.id && requestingUserOrgId
91 ? await getUserEffectivePermissions(request.userAuth.id, requestingUserOrgId)
92 : new Set<string>();
93 const canReadUsers = userPermissions.has(PERMISSIONS.users.read.value);
94
95 if (requestingUserType === 'customer') {
96 throw new StatusError(403, 'Customers cannot view user lists');
97 }
98
99 if (requestingUserType === 'crm') {
100 if (userType === 'crm' && !canReadUsers) {
101 throw new StatusError(403, 'Insufficient permissions to view CRM users');
102 }
103
104 if (userType === 'customer' || !userType) {
105 if (queryOrganizationId && queryOrganizationId !== requestingUserOrgId) {
106 throw new StatusError(403, 'CRM users can only view customers from their own organization');
107 }
108 }
109 }
110
111 const pageNum = page ? parseInt(page) : 1;
112 const limitNum = limit ? parseInt(limit) : 1000;
113 const offset = (pageNum - 1) * limitNum;
114 const field = sortField ?? 'lastName';
115 const order = sortOrder === 'asc' ? 'ASC' : 'DESC';
116 const searchTerm = search ? search.trim() : '';
117
118 if (userType === 'customer') {
119 throw new StatusError(400, 'Use /customers endpoint for customer queries');
120 }
121
122 const whereClause: UserWhereClause = {
123 deleted: false,
124 };
125
126 if (searchTerm) {
127 whereClause[Op.or] = [
128 where(fn('LOWER', fn('concat', col('firstName'), ' ', col('lastName'))), {
129 [Op.like]: `%${searchTerm.toLowerCase()}%`,
130 }),
131 where(fn('LOWER', col('email')), { [Op.like]: `%${searchTerm.toLowerCase()}%` }),
132 ];
133 }
134
135 if (userType === 'crm') {
136 whereClause.userType = 'crm';
137 } else if (requestingUserType === 'crm' && !canReadUsers) {
138 throw new StatusError(403, 'Insufficient permissions to view users');
139 }
140
141 let orderClause: Array<[string | ReturnType<typeof fn>, string] | [string, string]>;
142 if (['firstName', 'lastName', 'email'].includes(field)) {
143 orderClause = [[fn('LOWER', col(field)), order]];
144 } else {
145 orderClause = [[field, order]];
146 }
147
148 orderClause.push(['id', 'ASC']);
149
150 const queryOptions: FindOptions<Model> & { distinct?: boolean } = {
151 attributes: {
152 exclude: ['password', 'refreshToken', 'twoFactorSecret', 'resetToken', 'resetTokenExpiry'],
153 },
154 where: whereClause,
155 limit: limitNum,
156 offset,
157 order: orderClause,
158 };
159
160 const effectiveOrgId = queryOrganizationId;
161
162 if (effectiveOrgId) {
163 queryOptions.include = [
164 {
165 model: UserOrganization.raw,
166 where: {
167 organizationId: effectiveOrgId,
168 isActive: true,
169 },
170 required: true,
171 attributes: [],
172 },
173 ];
174 queryOptions.distinct = true;
175 }
176
177 const { count, rows: users } = await User.findAndCountAll(queryOptions);
178
179 const totalPages = Math.ceil(count / limitNum);
180
181 const responseData = {
182 users,
183 currentPage: pageNum,
184 totalPages,
185 totalUsers: count,
186 sortField: field,
187 sortOrder: order,
188 };
189
190 return {
191 status: 'success',
192 data: responseData,
193 };
194 };
195
196 export const getUserById = async (
197 request: FastifyRequest<{ Params: UserParams }>,
198 _reply: FastifyReply
199 ) => {
200 void _reply;
201 const params = request.params as unknown as Record<string, string>;
202 const paramName = Object.keys(params)[0];
203 const paramValue = Object.values(params)[0];
204
205 if (!paramValue) {
206 throw new StatusError(400, 'User ID is required');
207 }
208
209 const requestingUserId = request.userAuth?.id || request.ident?.user?.id;
210 const requestingUserRole = request.userAuth?.role || request.ident?.user?.role;
211 const requestingUserOrgId = request.userAuth?.organizationId || request.ident?.org?.id;
212 const isClientAuth =
213 request.auth?.method === 'client' ||
214 request.auth?.method === 'widget' ||
215 (request.auth?.method === 'bearer' && request.auth.bearer?.client !== null);
216
217 if (!requestingUserId && !isClientAuth) {
218 throw new StatusError(401, 'Authentication required');
219 }
220
221 const includeOptions = {
222 model: Organization,
223 as: 'organizations',
224 through: {
225 attributes: ['role', 'isActive', 'createdAt'],
226 where: { isActive: true },
227 },
228 attributes: ['id', 'displayName', 'name', 'description'],
229 };
230
231 const userPermissionIncludeOptions = {
232 model: UserPermission.raw,
233 as: 'userPermissions',
234 required: false,
235 attributes: ['id', 'permission', 'granted', 'organizationId'],
236 };
237
238 let user;
239
240 if (paramName === 'id') {
241 user = await User.findByPk(paramValue, {
242 attributes: { exclude: ['password'] },
243 include: [includeOptions, userPermissionIncludeOptions],
244 });
245 } else {
246 user = await User.findOne({
247 where: {
248 [paramName]: paramValue,
249 },
250 attributes: { exclude: ['password'] },
251 include: [includeOptions, userPermissionIncludeOptions],
252 });
253 }
254
255 if (!user) {
256 throw new StatusError(404, 'User{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/services/userService.ts"}1 import { FastifyRequest } from 'fastify';
2 import { getPortalUrl } from '../../lib.js';
3 import { Customer } from '@/models/Customer.js';
4 import { Organization } from '@/models/Organization.js';
5 import { generateResetToken } from '@/utils/crypto.js';
6
7 function extractEmailFromParameters(parameters: Record<string, unknown>): string | null {
8 if (parameters.personal && typeof parameters.personal === 'object') {
9 const personal = parameters.personal as Record<string, unknown>;
10 if (personal.email && typeof personal.email === 'string') {
11 return personal.email;
12 }
13 }
14
15 if (parameters.email && typeof parameters.email === 'string') {
16 return parameters.email;
17 }
18
19 return null;
20 }
21
22 function getNestedValue(obj: Record<string, unknown>, path: string): string | null {
23 const keys = path.split('.');
24 let current: unknown = obj;
25
26 for (const key of keys) {
27 if (current && typeof current === 'object' && key in current) {
28 current = (current as Record<string, unknown>)[key];
29 } else {
30 return null;
31 }
32 }
33
34 if (typeof current === 'string' && current.trim()) {
35 return current.trim();
36 }
37
38 return null;
39 }
40
41 export function normalizeAddressAutocomplete(
42 parameters: Record<string, unknown>
43 ): Record<string, unknown> {
44 const normalized = JSON.parse(JSON.stringify(parameters)) as Record<string, unknown>;
45
46 // Field mapping from addressAutocomplete to standard address fields
47 const fieldMapping: Record<string, string> = {
48 streetAddress: 'streetName',
49 city: 'city',
50 state: 'state',
51 country: 'country',
52 postalCode: 'postalCode',
53 };
54
55 function normalizeObject(obj: unknown, parentPath: string[] = []): void {
56 if (!obj || typeof obj !== 'object') {
57 return;
58 }
59
60 const record = obj as Record<string, unknown>;
61
62 // Check if this object has an addressAutocomplete property
63 if (
64 'addressAutocomplete' in record &&
65 record.addressAutocomplete &&
66 typeof record.addressAutocomplete === 'object'
67 ) {
68 const addressAutocomplete = record.addressAutocomplete as Record<string, unknown>;
69
70 // Only normalize if the parent path ends with 'address' (e.g., personal.address)
71 const isAddressPath =
72 parentPath.length > 0 && parentPath[parentPath.length - 1] === 'address';
73
74 if (isAddressPath) {
75 // Promote addressAutocomplete fields to the parent address level
76 for (const [autoCompleteField, standardField] of Object.entries(fieldMapping)) {
77 if (autoCompleteField in addressAutocomplete) {
78 const value = addressAutocomplete[autoCompleteField];
79 // Only promote if the standard field doesn't already exist
80 if (!(standardField in record) || !record[standardField]) {
81 record[standardField] = value;
82 }
83 }
84 }
85 }
86 }
87
88 // Recursively normalize nested objects
89 for (const [key, value] of Object.entries(record)) {
90 if (value && typeof value === 'object' && !Array.isArray(value)) {
91 normalizeObject(value, [...parentPath, key]);
92 }
93 }
94 }
95
96 normalizeObject(normalized);
97 return normalized;
98 }
99
100 function mapParametersToCustomerFields(parameters: Record<string, unknown>): Partial<Customer> {
101 const normalizedParameters = normalizeAddressAutocomplete(parameters);
102
103 const customerFields: Partial<Customer> = {};
104
105 const mappings = [
106 { paramPath: 'personal.firstName', field: 'firstName' as keyof Customer },
107 { paramPath: 'personal.lastName', field: 'lastName' as keyof Customer },
108 { paramPath: 'personal.email', field: 'email' as keyof Customer },
109 { paramPath: 'personal.mobilePhone', field: 'phoneNumber' as keyof Customer },
110 { paramPath: 'personal.address.streetName', field: 'address' as keyof Customer },
111 { paramPath: 'personal.address.city', field: 'city' as keyof Customer },
112 { paramPath: 'personal.address.state', field: 'region' as keyof Customer },
113 { paramPath: 'personal.address.country', field: 'country' as keyof Customer },
114 { paramPath: 'personal.address.postalCode', field: 'postalCode' as keyof Customer },
115 ];
116
117 for (const mapping of mappings) {
118 const value = getNestedValue(normalizedParameters, mapping.paramPath);
119 if (value) {
120 (customerFields as Record<string, unknown>)[mapping.field] = value;
121 }
122 }
123
124 return customerFields;
125 }
126
127 export async function createCustomerFromPolicyParameters(
128 parameters: Record<string, unknown>,
129 organizationId: string,
130 externalClientId?: string
131 ): Promise<Customer | null> {
132 const email = extractEmailFromParameters(parameters);
133
134 if (!email && !externalClientId) {
135 return null;
136 }
137
138 // Lookup by email first, then by externalClientId
139 const existingCustomer = email
140 ? await Customer.findOne({ organizationId }, {
141 where: { email: email.toLowerCase(), organizationId, deleted: false },
142 })
143 : await Customer.findOne({ organizationId }, {
144 where: { externalClientId, organizationId, deleted: false },
145 });
146
147 if (existingCustomer) {
148 const mappedFields = mapParametersToCustomerFields(parameters);
149 const updateData: Record<string, unknown> = {};
150
151 for (const key of ['firstName', 'lastName', 'phoneNumber', 'address', 'city', 'region', 'country', 'postalCode'] as const) {
152 if (mappedFields[key as keyof Customer]) {
153 updateData[key] = mappedFields[key as keyof Customer];
154 }
155 }
156
157 // Back-fill externalClientId on an email-matched customer that doesn't have
158 // one yet. We match by email first, so a checkout that also supplies an
159 // externalClientId would otherwise never persist it — and the customer
160 // portal resolves sessions exclusively by externalClientId, leaving the
161 // customer un-findable. Only set when empty so we never clobber an existing
162 // partner identifier.
163 if (externalClientId && !existingCustomer.externalClientId) {
164 updateData.externalClientId = externalClientId;
165 }
166
167 // Only mint a new reset token when the customer doesn't already hold a
168 // valid one. Rotating on every find-or-create would invalidate the link in
169 // an earlier welcome email the moment the same customer re-submits a form
170 // (or the activation hook touches the row), which broke create-account.
171 if (email && !existingCustomer.password) {
172 const tokenStillValid =
173 !!existingCustomer.resetToken &&
174 !!existingCustomer.resetTokenExpiry &&
175 existingCustomer.resetTokenExpiry.getTime() > Date.now();
176 if (!tokenStillValid) {
177 updateData.resetToken = generateResetToken();
178 updateData.resetTokenExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
179 }
180 }
181
182 if (Object.keys(updateData).length > 0) {
183 await existingCustomer.update(updateData);
184 }
185
186 return existingCustomer;
187 }
188
189 const mappedFields = mapParametersToCustomerFields(parameters);
190
191 const customer = await Customer.create({ organizationId }, {
192 organizationId,
193 externalClientId: externalClientId || null,
194 firstName: mappedFields.firstName || '',
195 lastName: mappedFields.lastName || '',
196 email: email ? email.toLowerCase() : `${externalClientId}@external.partner`,
197 password: null,
198 phoneNumber: mappedFields.phoneNumber || null,
199 country: mappedFields.country || nuRoot cause clear. List endpoint getAllUsers (userController.ts:73-194): hardcodes deleted: false (line 122-124), so inactive users never returned. NO org include unless organizationId filter passed, and even then attributes: [] (org NOT in payload, just join filter). So list NEVER returns organizations array. Detail endpoint DOES (lines 221-229). UsersQuery (lines 23-32) has no active/status param.
Need: User model (is there separate active vs deleted?), UserOrganization model + schema, routes (incl plus board).
{"command":"fd -t f 'User.ts$' src/models --max-results 5; echo \"=== User model head ===\"; sed -n '1,140p' src/models/User.ts 2>/dev/null || cat src/models/User.ts | head -140","description":"Locate + read User model fields"}src/models/User.ts
=== User model head ===
import bcrypt from 'bcryptjs';
import {
Table,
Column,
Model,
HasMany,
BelongsToMany,
DataType,
BeforeCreate,
BeforeUpdate,
} from 'sequelize-typescript';
import { Organization } from './Organization';
import { Product, _Product } from './Product';
import { UserOrganization, _UserOrganization } from './UserOrganization';
import { UserPermission, _UserPermission } from './UserPermission.js';
import { ROLE_LEVELS, type RoleLevel } from '@/types/rbac.js';
@Table({
modelName: 'User',
timestamps: true,
indexes: [
{
fields: ['userType'],
name: 'idx_users_user_type',
},
{
fields: ['email', 'userType'],
unique: true,
name: 'users_email_usertype_unique',
},
],
})
export class User extends Model {
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare companyName: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare externalClientId: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare firstName: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare lastName: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
validate: {
isEmail: true,
},
})
declare email: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare password: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare phoneNumber: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare country: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare region: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare address: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare city: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare postalCode: string | null;
@Column({
type: DataType.ENUM(...ROLE_LEVELS),
defaultValue: 'contributor',
})
declare role: RoleLevel;
@Column({
type: DataType.ENUM('crm', 'customer'),
allowNull: false,
defaultValue: 'customer',
})
declare userType: 'crm' | 'customer';
@Column({
type: DataType.ENUM('local', 'social', 'azure-ad'),
allowNull: false,
defaultValue: 'local',
})
declare authProvider: 'local' | 'social' | 'azure-ad';
@Column({
type: DataType.STRING,
allowNull: true,
unique: true,
}){"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/models/UserOrganization.ts"}1 import { _UserOrganization } from './internal/_UserOrganization.js';
2 import { toSafeModel } from './lib/safe-model.js';
3
4 export const UserOrganization = toSafeModel(_UserOrganization);
5 export type UserOrganization = _UserOrganization;
6
7 // Re-export raw class for db.ts registration, association includes, and type usage
8 export { _UserOrganization };
9 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/routes/userRoutes.ts"}1 import { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
2 import { listCustomers, updateCustomer, deleteCustomer } from '@/controllers/customerController.js';
3 import { usersQuerystringSchema, userParamsSchema, updateUserBodySchema } from '@/schemas/user.schemas.js';
4
5 interface CustomersQuery {
6 page?: string;
7 limit?: string;
8 sortField?: string;
9 sortOrder?: string;
10 search?: string;
11 }
12
13 interface CustomerParams {
14 userId: string;
15 }
16
17 interface UpdateCustomerBody {
18 firstName?: string;
19 lastName?: string;
20 email?: string;
21 phoneNumber?: string;
22 companyName?: string;
23 country?: string;
24 region?: string;
25 address?: string;
26 city?: string;
27 postalCode?: string;
28 }
29
30 export const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
31 fastify.get<{ Querystring: CustomersQuery }>('/', {
32 preHandler: [fastify.authenticateScope(['users:read'])],
33 schema: {
34 description: 'Lists customers in the active organization, paginated. Despite the URL ("/v1/users"), this endpoint operates on the Customers table — it is the Sherpa Plus CRM admin "Users" view used by back-office staff to browse end-customers. Internal — used by the Sherpa Plus portal CRM screens; partner integrations should use the dedicated /v1/customers endpoints instead.',
35 tags: ['Sherpa Plus — Users'],
36 hide: true,
37 security: [{ clientCredentials: [] }],
38 querystring: usersQuerystringSchema,
39 response: {
40 200: {
41 type: 'object',
42 additionalProperties: true,
43 properties: {
44 status: { type: 'string', example: 'success' },
45 data: {
46 type: 'object',
47 additionalProperties: true,
48 properties: {
49 users: {
50 type: 'array',
51 items: { type: 'object', additionalProperties: true },
52 },
53 currentPage: { type: 'integer', example: 1 },
54 totalPages: { type: 'integer', example: 5 },
55 totalUsers: { type: 'integer', example: 100 },
56 sortField: { type: 'string', example: 'lastName' },
57 sortOrder: { type: 'string', example: 'ASC' },
58 },
59 },
60 },
61 },
62 },
63 },
64 }, listCustomers);
65
66 fastify.put<{ Params: CustomerParams; Body: UpdateCustomerBody }>('/:userId', {
67 preHandler: [fastify.authenticateScope(['users:write'])],
68 schema: {
69 description: 'Updates a single customer record from the Sherpa Plus CRM admin "Users" view. Despite the URL, this writes to the Customers table. Internal — used by the Sherpa Plus portal; partner integrations should use the dedicated /v1/customers endpoints instead.',
70 tags: ['Sherpa Plus — Users'],
71 hide: true,
72 security: [{ clientCredentials: [] }],
73 params: userParamsSchema,
74 body: updateUserBodySchema,
75 response: {
76 200: {
77 type: 'object',
78 additionalProperties: true,
79 properties: {
80 status: { type: 'string', example: 'success' },
81 data: {
82 type: 'object',
83 additionalProperties: true,
84 properties: {
85 id: { type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000' },
86 firstName: { type: 'string', example: 'Alex' },
87 lastName: { type: 'string', example: 'Stone' },
88 email: { type: 'string', format: 'email', example: '[email protected]' },
89 userType: { type: 'string', example: 'customer' },
90 role: { type: 'string', example: 'customer' },
91 },
92 },
93 },
94 },
95 },
96 },
97 }, async (request: FastifyRequest<{ Params: CustomerParams; Body: UpdateCustomerBody }>, reply: FastifyReply) => {
98 const modifiedRequest = {
99 ...request,
100 params: { id: request.params.userId }
101 } as unknown as FastifyRequest<{ Params: { id: string }; Body: UpdateCustomerBody }>;
102
103 return await updateCustomer(modifiedRequest, reply);
104 });
105
106 fastify.delete<{ Params: CustomerParams }>('/:userId', {
107 preHandler: [fastify.authenticateScope(['users:delete'])],
108 schema: {
109 description: 'Soft-deletes a customer record from the Sherpa Plus CRM admin "Users" view. Despite the URL, this targets the Customers table. Internal — used by the Sherpa Plus portal; partner integrations should use the dedicated /v1/customers endpoints instead.',
110 tags: ['Sherpa Plus — Users'],
111 hide: true,
112 security: [{ clientCredentials: [] }],
113 params: userParamsSchema,
114 response: {
115 200: {
116 type: 'object',
117 additionalProperties: true,
118 properties: {
119 status: { type: 'string', example: 'success' },
120 message: { type: 'string', example: 'Customer deleted successfully' },
121 },
122 },
123 },
124 },
125 }, async (request: FastifyRequest<{ Params: CustomerParams }>, reply: FastifyReply) => {
126 const modifiedRequest = {
127 ...request,
128 params: { id: request.params.userId }
129 } as unknown as FastifyRequest<{ Params: { id: string } }>;
130
131 return await deleteCustomer(modifiedRequest, reply);
132 });
133 };
134
135 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/routes/plus/userRoutes.ts"}1 import { FastifyInstance, FastifyPluginAsync } from 'fastify';
2
3 import {
4 getAllUsers,
5 getUserById,
6 updateUser,
7 deleteUser,
8 getProfile,
9 updateProfile,
10 createUser,
11 updateUserPermissions,
12 } from '../../controllers/userController.js';
13 import {
14 userIdParamsSchema,
15 updateProfileBodySchema,
16 getAllUsersQuerystringSchema,
17 createUserBodySchema,
18 updateUserBodySchema,
19 updateUserPermissionsBodySchema,
20 } from '../../schemas/plus/user.schemas.js';
21 import { PERMISSIONS } from '@/types/permissions.js';
22
23 const TAG = 'Sherpa Plus — Users';
24
25 export const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
26 await fastify.register(async function (fastify) {
27 fastify.addHook('preHandler', fastify.authenticate);
28
29 const userEnvelopeResponse = {
30 200: {
31 type: 'object',
32 properties: {
33 status: { type: 'string', example: 'success' },
34 data: {
35 type: 'object',
36 additionalProperties: true,
37 properties: {
38 user: {
39 type: 'object',
40 additionalProperties: true,
41 properties: {
42 id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' },
43 firstName: { type: 'string', example: 'Alex' },
44 lastName: { type: 'string', example: 'Stone' },
45 email: { type: 'string', example: '[email protected]' },
46 role: { type: 'string', example: 'super_admin' },
47 userType: { type: 'string', example: 'crm' },
48 authProvider: { type: 'string', example: 'azure-ad' },
49 phoneNumber: { type: ['string', 'null'], example: null },
50 companyName: { type: ['string', 'null'], example: null },
51 country: { type: ['string', 'null'], example: null },
52 region: { type: ['string', 'null'], example: null },
53 address: { type: ['string', 'null'], example: null },
54 city: { type: ['string', 'null'], example: null },
55 postalCode: { type: ['string', 'null'], example: null },
56 externalClientId: { type: ['string', 'null'], example: null },
57 azureObjectId: { type: ['string', 'null'], example: null },
58 azureTenantId: { type: ['string', 'null'], example: null },
59 azureRoles: { type: ['array', 'null'], items: { type: 'string' } },
60 twoFactorMethod: { type: ['string', 'null'], example: 'none' },
61 twoFactorEnabled: { type: 'boolean', example: false },
62 deleted: { type: 'boolean', example: false },
63 organizations: {
64 type: 'array',
65 items: { type: 'object', additionalProperties: true },
66 },
67 userPermissions: {
68 type: 'array',
69 items: { type: 'object', additionalProperties: true },
70 },
71 createdAt: { type: 'string', format: 'date-time' },
72 updatedAt: { type: 'string', format: 'date-time' },
73 },
74 },
75 },
76 },
77 },
78 },
79 } as const;
80
81 // Get current user profile
82 fastify.get(
83 '/profile',
84 {
85 schema: {
86 description:
87 'Returns the authenticated user profile, including the user record and their organization memberships. Internal endpoint used by the Sherpa Portal navigation and account screens. Requires user-bound credentials; client-only credentials without a user binding return 400.',
88 tags: [TAG],
89 security: [{ clientCredentials: [] }],
90 response: userEnvelopeResponse,
91 },
92 },
93 getProfile
94 );
95
96 // Update current user profile
97 fastify.patch(
98 '/profile',
99 {
100 schema: {
101 description:
102 'Updates the authenticated user\'s own profile fields (name, contact details, address). Cannot be used to change role, organization or deletion state — those require the users:update permission via the /:id endpoint. Returns the updated user envelope.',
103 tags: [TAG],
104 security: [{ clientCredentials: [] }],
105 body: updateProfileBodySchema,
106 response: userEnvelopeResponse,
107 },
108 },
109 updateProfile
110 );
111
112 // Get user by ID (admin only or own profile)
113 fastify.get(
114 '/:id',
115 {
116 schema: {
117 description:
118 'Looks up a user by ID. Users may always read their own record; CRM users may read customers in the same organization; otherwise the users:read permission is required. Includes the user\'s active organization memberships and explicit permission grants.',
119 tags: [TAG],
120 security: [{ clientCredentials: [] }],
121 params: userIdParamsSchema,
122 response: userEnvelopeResponse,
123 },
124 },
125 getUserById
126 );
127
128 // Get all users
129 fastify.get<{
130 Querystring: {
131 page?: string;
132 limit?: string;
133 search?: string;
134 sortField?: string;
135 sortOrder?: string;
136 role?: string;
137 userType?: string;
138 organizationId?: string;
139 };
140 }>(
141 '/',
142 {
143 schema: {
144 description:
145 'Lists CRM users in the platform with pagination, search and sort. CRM users without users:read are limited to viewing customers from their own organization; super-admins may scope by organizationId. Customer queries must use the /customers endpoint.',
146 tags: [TAG],
147 security: [{ clientCredentials: [] }],
148 querystring: getAllUsersQuerystringSchema,
149 response: {
150 200: {
151 type: 'object',
152 properties: {
153 status: { type: 'string', example: 'success' },
154 data: {
155 type: 'object',
156 additionalProperties: true,
157 properties: {
158 users: {
159 type: 'array',
160 items: { type: 'object', additionalProperties: true },
161 },
162 currentPage: { type: 'integer', example: 1 },
163 totalPages: { type: 'integer', example: 5 },
164 totalUsers: { type: 'integer', example: 100 },
165 sortField: { type: 'string', example: 'lastName' },
166 sortOrder: { type: 'string', example: 'ASC' },
167 },
168 },
169 },
170 },
171 },
172 },
173 },
174 getAllUsers
175 );
176
177 // Create user (admin only)
178 fastify.post<{
179 Body: {
180 firstName: string;
181 lastName: string;
182 email: string;
183 role?: string;
184 phoneNumber?: string;
185 companyName?: string;
186 country?: string;
187 region?: string;
188 address?: string;
189 city?: string;
190 postalCode?: string;
191 organizationId: string;
192 };
193 }>(
194 '/',
195 {
196 schema: {
197 description:
198 'Creates a CRM user and links them to the given organization. When Azure AD is enabled the user is provisioned via Azure AD invitation (or linked ifTwo /users routes: routes/userRoutes.ts hits Customers table; routes/plus/userRoutes.ts is CRM Users (getAllUsers/getUserById/createUser). Board=plus + admin UsersTab uses userType=crm → plus route is the target. Note plus list response (lines 158-161) is additionalProperties: true so adding fields needs no schema change, but querystring is validated.
Need: rest of User model (deleted vs separate active, associations), plus user schemas (querystring), UserOrganization internal fields, route mount prefix, sherpa-test specs.
{"command":"echo \"=== User.ts lines 140-320 ===\"; sed -n '140,320p' src/models/User.ts","description":"Read rest of User model (deleted, associations)"}=== User.ts lines 140-320 ===
})
declare azureObjectId: string | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare azureTenantId: string | null;
@Column({
type: DataType.ARRAY(DataType.STRING),
allowNull: true,
})
declare azureRoles: string[] | null;
@Column({
type: DataType.TEXT,
allowNull: true,
})
declare refreshToken: string | null;
@Column({
type: DataType.BOOLEAN,
defaultValue: false,
})
declare deleted: boolean;
@Column({
type: DataType.ENUM('none', 'app', 'email'),
defaultValue: 'none',
})
declare twoFactorMethod: 'none' | 'app' | 'email';
@Column({
type: DataType.BOOLEAN,
defaultValue: false,
})
declare twoFactorEnabled: boolean;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare twoFactorSecret: string | null;
@Column({
type: DataType.ARRAY(DataType.STRING),
allowNull: true,
})
declare twoFactorBackupCodes: string[] | null;
@Column({
type: DataType.STRING,
allowNull: true,
})
declare twoFactorEmailToken: string | null;
@Column({
type: DataType.DATE,
allowNull: true,
})
declare twoFactorEmailTokenExpires: Date | null;
@Column({
type: DataType.TEXT,
allowNull: true,
})
declare resetToken: string | null;
@Column({
type: DataType.DATE,
allowNull: true,
})
declare resetTokenExpiry: Date | null;
@Column({
type: DataType.DATE,
allowNull: false,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
allowNull: false,
})
declare updatedAt: Date;
@HasMany(() => _Product, {
foreignKey: 'userId',
as: 'products',
})
declare products: Product[];
@HasMany(() => _UserOrganization, {
foreignKey: 'userId',
as: 'userOrganizations',
})
declare userOrganizations?: UserOrganization[];
@BelongsToMany(() => Organization, {
through: () => _UserOrganization,
foreignKey: 'userId',
otherKey: 'organizationId',
as: 'organizations',
})
declare organizations?: Organization[];
@HasMany(() => _UserPermission, {
foreignKey: 'userId',
as: 'userPermissions',
})
declare userPermissions?: UserPermission[];
@BeforeCreate
static async hashPasswordBeforeCreate(instance: User) {
if (instance.password) {
instance.password = await bcrypt.hash(instance.password, 10);
}
}
@BeforeUpdate
static async hashPasswordBeforeUpdate(instance: User) {
if (instance.changed('password') && instance.password) {
instance.password = await bcrypt.hash(instance.password, 10);
}
}
// Helper methods
public isCrmUser(): boolean {
return this.userType === 'crm';
}
public isCustomerUser(): boolean {
return this.userType === 'customer';
}
public isAzureAdUser(): boolean {
return this.authProvider === 'azure-ad';
}
public hasAzureRole(role: string): boolean {
return this.azureRoles?.includes(role) ?? false;
}
// Organization-related helper methods
public async getOrganizationSettings() {
if (!this.organizations || this.organizations.length === 0) {
await this.reload({ include: [Organization] });
}
return this.organizations?.[0]?.settings || {};
}
public async isOrganizationFeatureEnabled(
feature: keyof NonNullable<import('./Organization').OrganizationSettings['features']>
): Promise<boolean> {
const settings = await this.getOrganizationSettings();
return !!settings.features?.[feature];
}
public async getOrganizationBrandingColor(): Promise<string> {
const settings = await this.getOrganizationSettings();
return settings.branding?.primaryColor || '#3B82F6';
}
public async getOrganizationApplicationName(): Promise<string> {
if (!this.organizations || this.organizations.length === 0) {
await this.reload({ include: [Organization] });
}
return this.organizations?.[0]?.getApplicationName() || 'Sherpa Portal';
}
}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/schemas/plus/user.schemas.ts"}1 /**
2 * Shared schemas for user related endpoints
3 */
4 import { Type } from '@sinclair/typebox';
5 import { ROLE_LEVELS } from '@/types/rbac.js';
6
7 export const userIdParamsSchema = Type.Object({
8 id: Type.String({ description: 'The ID of the user', example: '550e8400-e29b-41d4-a716-446655440000' }),
9 });
10
11 export const updateProfileBodySchema = Type.Object({
12 firstName: Type.Optional(Type.String({ description: 'User\'s first name', example: 'Alex' })),
13 lastName: Type.Optional(Type.String({ description: 'User\'s last name', example: 'Stone' })),
14 phoneNumber: Type.Optional(Type.String({ description: 'User\'s phone number', example: '+34 600 123 456' })),
15 companyName: Type.Optional(Type.String({ description: 'User\'s company name', example: 'Gangkhar' })),
16 country: Type.Optional(Type.String({ description: 'User\'s country', example: 'ES' })),
17 region: Type.Optional(Type.String({ description: 'User\'s region or state', example: 'Catalonia' })),
18 address: Type.Optional(Type.String({ description: 'User\'s street address', example: 'Carrer de Mallorca 401' })),
19 city: Type.Optional(Type.String({ description: 'User\'s city', example: 'Barcelona' })),
20 postalCode: Type.Optional(Type.String({ description: 'User\'s postal code', example: '08013' })),
21 email: Type.Optional(Type.String({ format: 'email', description: 'User\'s email address', example: '[email protected]' })),
22 });
23
24 export const getAllUsersQuerystringSchema = Type.Object({
25 page: Type.Optional(Type.String({ description: 'Page number for pagination', example: '1' })),
26 limit: Type.Optional(Type.String({ description: 'Number of items per page', example: '20' })),
27 search: Type.Optional(Type.String({ description: 'Search term to filter users by name or email', example: 'alex.stone' })),
28 sortField: Type.Optional(Type.String({ description: 'Field to sort by (e.g., firstName, lastName, email)', example: 'lastName' })),
29 sortOrder: Type.Optional(Type.String({ enum: ['asc', 'desc'], description: 'Sort order: ascending or descending', example: 'asc' })),
30 role: Type.Optional(Type.String({ enum: [...ROLE_LEVELS], description: 'Filter by user role', example: ROLE_LEVELS[0] })),
31 userType: Type.Optional(Type.String({ enum: ['crm', 'customer'], description: 'Filter by user type', example: 'crm' })),
32 organizationId: Type.Optional(Type.String({ description: 'Filter by organization ID', example: '550e8400-e29b-41d4-a716-446655440000' })),
33 });
34
35 export const createUserBodySchema = Type.Object({
36 firstName: Type.String({ description: 'User\'s first name', example: 'Alex' }),
37 lastName: Type.String({ description: 'User\'s last name', example: 'Stone' }),
38 email: Type.String({ format: 'email', description: 'User\'s email address', example: '[email protected]' }),
39 role: Type.Optional(Type.String({ enum: [...ROLE_LEVELS], description: 'User role', example: ROLE_LEVELS[0] })),
40 phoneNumber: Type.Optional(Type.String({ description: 'User\'s phone number', example: '+34 600 123 456' })),
41 companyName: Type.Optional(Type.String({ description: 'User\'s company name', example: 'Gangkhar' })),
42 country: Type.Optional(Type.String({ description: 'User\'s country', example: 'ES' })),
43 region: Type.Optional(Type.String({ description: 'User\'s region or state', example: 'Catalonia' })),
44 address: Type.Optional(Type.String({ description: 'User\'s street address', example: 'Carrer de Mallorca 401' })),
45 city: Type.Optional(Type.String({ description: 'User\'s city', example: 'Barcelona' })),
46 postalCode: Type.Optional(Type.String({ description: 'User\'s postal code', example: '08013' })),
47 organizationId: Type.String({ description: 'The ID of the organization to add the user to', example: '550e8400-e29b-41d4-a716-446655440000' }),
48 });
49
50 export const updateUserBodySchema = Type.Object({
51 firstName: Type.Optional(Type.String({ description: 'User\'s first name', example: 'Alex' })),
52 lastName: Type.Optional(Type.String({ description: 'User\'s last name', example: 'Stone' })),
53 email: Type.Optional(Type.String({ format: 'email', description: 'User\'s email address', example: '[email protected]' })),
54 role: Type.Optional(Type.String({ enum: [...ROLE_LEVELS], description: 'User role', example: ROLE_LEVELS[0] })),
55 phoneNumber: Type.Optional(Type.String({ description: 'User\'s phone number', example: '+34 600 123 456' })),
56 companyName: Type.Optional(Type.String({ description: 'User\'s company name', example: 'Gangkhar' })),
57 country: Type.Optional(Type.String({ description: 'User\'s country', example: 'ES' })),
58 region: Type.Optional(Type.String({ description: 'User\'s region or state', example: 'Catalonia' })),
59 address: Type.Optional(Type.String({ description: 'User\'s street address', example: 'Carrer de Mallorca 401' })),
60 city: Type.Optional(Type.String({ description: 'User\'s city', example: 'Barcelona' })),
61 postalCode: Type.Optional(Type.String({ description: 'User\'s postal code', example: '08013' })),
62 active: Type.Optional(Type.Boolean({ description: 'Whether the user account is active', example: true })),
63 twoFactorEnabled: Type.Optional(Type.Boolean({ description: 'Whether two-factor authentication is enabled', example: false })),
64 deleted: Type.Optional(Type.Boolean({ description: 'Whether the user account is deleted (soft delete)', example: false })),
65 organizationId: Type.Optional(Type.String({ description: 'The ID of the organization', example: '550e8400-e29b-41d4-a716-446655440000' })),
66 });
67
68 export const updateUserPermissionsBodySchema = Type.Object({
69 permissionIds: Type.Array(Type.String(), {
70 description: 'Array of permission strings to assign to the user (e.g., "policies:read")',
71 example: ['policies:read', 'quotes:read', 'quotes:write'],
72 }),
73 });
74 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/models/internal/_UserOrganization.ts"}1 import { Table, Column, Model, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
2 import { Organization } from '../Organization.js';
3 import { User } from '../User.js';
4 import { ROLE_LEVELS, type RoleLevel } from '@/types/rbac.js';
5
6 @Table({
7 modelName: 'UserOrganization',
8 timestamps: true,
9 indexes: [
10 {
11 unique: true,
12 fields: ['userId', 'organizationId'],
13 name: 'unique_user_organization',
14 },
15 {
16 fields: ['userId'],
17 name: 'idx_user_organization_user_id',
18 },
19 {
20 fields: ['organizationId'],
21 name: 'idx_user_organization_org_id',
22 },
23 {
24 fields: ['isActive'],
25 name: 'idx_user_organization_active',
26 },
27 ],
28 })
29 export class _UserOrganization extends Model {
30 @Column({
31 type: DataType.INTEGER,
32 autoIncrement: true,
33 primaryKey: true,
34 })
35 declare id: number;
36
37 @ForeignKey(() => User)
38 @Column({
39 type: DataType.UUID,
40 allowNull: false,
41 })
42 declare userId: string;
43
44 @ForeignKey(() => Organization)
45 @Column({
46 type: DataType.UUID,
47 allowNull: false,
48 })
49 declare organizationId: string;
50
51 @Column({
52 type: DataType.ENUM(...ROLE_LEVELS),
53 allowNull: true,
54 })
55 declare role: RoleLevel | null;
56
57 @Column({
58 type: DataType.BOOLEAN,
59 allowNull: false,
60 defaultValue: true,
61 })
62 declare isActive: boolean;
63
64 @Column({
65 type: DataType.DATE,
66 allowNull: false,
67 })
68 declare createdAt: Date;
69
70 @Column({
71 type: DataType.DATE,
72 allowNull: false,
73 })
74 declare updatedAt: Date;
75
76 @BelongsTo(() => User)
77 declare user: User;
78
79 @BelongsTo(() => Organization)
80 declare organization: Organization;
81 }
82 Critical nuance: User model has NO active/isActive column. Only deleted: boolean (User.ts ~165). So user "active" = !deleted (matches UserDetail toggle). BUT updateUserBodySchema (plus/user.schemas.ts:62) has phantom active field not on model. User model also has NO organizationId column (org moved to join table) so getOrganizationName(user.organizationId) always returns "" = the reported "not displayed at all" bug. UserOrganization.isActive (line 57-62) = per-membership active flag = "active organizations".
Final batch: route mount prefix, portal api base, ui primitives (tooltip/badge/popover for overflow), sherpa-test specs, AGENTS.md.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-api\necho \"=== where plus userRoutes registered (prefix) ===\"\nrg -n \"plus/userRoutes|userRoutes\" src --glob '!**/userRoutes.ts' -g '*.ts' | head -20\necho \"=== plus register prefix ===\"\nrg -n \"register.*[Uu]serRoutes|prefix.*users|'/users'|\\\"/users\\\"\" src/routes src/app.ts src/server.ts src/index.ts 2>/dev/null | head -20","description":"Find plus userRoutes mount prefix"}=== where plus userRoutes registered (prefix) ===
src/routes/userRoutes.ts:30:export const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
src/routes/plus/userRoutes.ts:25:export const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
src/routes/plus/index.ts:54:import { userRoutes } from './userRoutes.js';
src/routes/plus/index.ts:97: await fastify.register(userRoutes, { prefix: '/users' });
src/routes.ts:135:import { userRoutes } from '@/routes/userRoutes.js';
src/routes.ts:289: await app.register(userRoutes, { prefix: '/v1/users' });
=== plus register prefix ===
src/routes/plus/organizationRoutes.ts:258: fastify.get<{ Querystring: UsersQuery }>('/users', {
src/routes/plus/index.ts:97: await fastify.register(userRoutes, { prefix: '/users' });{"command":"cd /Users/bakker/dev/gangkhar/sherpa-portal\necho \"=== api util base ===\"; sed -n '1,60p' app/src/utils/api.ts 2>/dev/null\necho \"=== ui primitives available ===\"; ls app/components/ui/ 2>/dev/null\necho \"=== tooltip/badge/popover present? ===\"; fd -t f -i 'tooltip|badge|popover' app/components/ui 2>/dev/null","description":"Portal api base + ui primitives"}=== api util base ===
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from "axios";
import { jwtDecode } from "jwt-decode";
import { getSherpaApiUrl } from "./env";
import { endSession } from "./session";
import useAuthStore from "../store/authStore";
import useConnectionStore from "../store/connectionStore";
// Paths that never carry a user's Authorization header. The request
// interceptor skips both the attach and the pre-refresh check on these; the
// response interceptor skips the refresh-and-retry chain on 401 from these.
// A 401 from a credential-less request is not a verdict on the user's session
// — see rfc/ideas/organization-resolution-and-auth-policy.md §6.1.
//
// INVARIANT: every entry MUST start with `/v1/`. The request interceptor
// rewrites legacy `/auth/...` and bare paths into `/v1/...` BEFORE this list
// is consulted, so any non-`/v1/` entry here is unreachable and indicates a
// refactor drift. The module-load assertion below fails fast if violated.
const NO_AUTH_PATHS = [
"/v1/auth/login",
"/v1/auth/customer/login",
"/v1/auth/register",
"/v1/auth/forgot-password",
"/v1/auth/refresh-token",
"/v1/auth/customer/refresh-token",
"/v1/organization",
"/v1/health/self",
];
// Fail fast on refactor breakage: surfaces a missing rewrite or a stale entry
// at startup rather than as a silent auth-attach regression at runtime.
for (const path of NO_AUTH_PATHS) {
if (!path.startsWith("/v1/")) {
throw new Error(
`NO_AUTH_PATHS invariant violated: "${path}" must start with "/v1/". ` +
`The request interceptor only sees rewritten /v1/* URLs.`,
);
}
}
function stripQuery(url: string | undefined): string {
return (url || "").split("?")[0];
}
function isNoAuthPath(url: string | undefined): boolean {
return NO_AUTH_PATHS.includes(stripQuery(url));
}
class ApiService {
private static instance: ApiService;
private api: AxiosInstance;
private isRefreshing: boolean = false;
private constructor() {
this.api = axios.create({
baseURL: getSherpaApiUrl(),
validateStatus: (status) => (status >= 200 && status < 300) || status === 304,
});
this.setupInterceptors();
=== ui primitives available ===
DynamicIcon.tsx
ExportDropdown.tsx
IconPicker.tsx
accordion.tsx
badge.tsx
button.tsx
card.tsx
checkbox.tsx
colorPicker.tsx
currencyDisplay.tsx
dialog.tsx
divider.tsx
input.tsx
label.tsx
multi-select.tsx
percentage.tsx
richTextarea.tsx
select.tsx
shadowPicker.tsx
splitButton.tsx
switch.tsx
table.tsx
tabs.tsx
textarea.tsx
tooltip.tsx
typed-text.tsx
=== tooltip/badge/popover present? ===
app/components/ui/badge.tsx
app/components/ui/tooltip.tsx{"command":"cd /Users/bakker/dev/gangkhar\necho \"=== sherpa-test specs touching users/admin ===\"\nfd -t f -e ts -e spec.ts . sherpa-test 2>/dev/null | rg -i 'user|admin' | head -30\necho \"=== rg admin?tab=users or UsersTab in tests ===\"\nrg -ln \"tab=users|UsersTab|/admin|users.*active|active.*users\" sherpa-test 2>/dev/null | head -20\necho \"=== AGENTS.md files ===\"\nfd -t f 'AGENTS.md' sherpa-portal sherpa-api sherpa-test 2>/dev/null | head -20","description":"sherpa-test user/admin specs + AGENTS.md"}=== sherpa-test specs touching users/admin === === rg admin?tab=users or UsersTab in tests === sherpa-test/tests/session-intent-capture.spec.ts === AGENTS.md files === sherpa-api/AGENTS.md sherpa-api/src/AGENTS.md sherpa-api/src/db/AGENTS.md sherpa-api/src/domains/lens/AGENTS.md sherpa-api/src/domains/lens/journey/AGENTS.md sherpa-api/src/domains/lens/optimizations/commission/AGENTS.md sherpa-api/src/domains/lens/optimizations/commission/quote/model-server/AGENTS.md sherpa-api/src/domains/lens/synthetic-client/AGENTS.md sherpa-api/src/domains/mcp/AGENTS.md sherpa-api/src/domains/mcp/openai/AGENTS.md sherpa-api/src/domains/payments/AGENTS.md sherpa-api/src/services/pricing/AGENTS.md sherpa-portal/AGENTS.md sherpa-portal/app/AGENTS.md sherpa-portal/app/lens/optimizations/commission/AGENTS.md sherpa-portal/app/lens/synthetic-client/AGENTS.md
Routing needs disambiguation: /v1/users (routes.ts:289) = Customers table (listCustomers), but admin UsersTab uses userType="crm". Must find where plus/index mounts (getAllUsers) + how portal interceptor rewrites bare /users. Critical for the ticket's API target.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-api\necho \"=== plus/index registration prefix ===\"\nrg -n \"plus/index|plusRoutes|register.*plus|from './plus'|from '@/routes/plus'\" src/routes.ts src/routes/index.ts 2>/dev/null | head\necho \"=== top of plus/index.ts ===\"\nsed -n '80,110p' src/routes/plus/index.ts\necho \"=== how is plus index registered + prefix ===\"\nrg -n \"plus\" src/routes.ts | head -20","description":"Resolve plus index mount prefix"}=== plus/index registration prefix ===
src/routes.ts:111:import { plusRoutes } from '@/routes/plus/index.js';
src/routes.ts:244: await app.register(plusRoutes, { prefix: '/v1/plus' });
=== top of plus/index.ts ===
});
await fastify.register(quoteRoutes, { prefix: '/quotes' });
await fastify.register(policyRoutes, { prefix: '/policies' });
await fastify.register(claimRoutes, { prefix: '/claims' });
await fastify.register(templateRoutes, { prefix: '/templates' });
await fastify.register(commissionStructureRoutes, { prefix: '/commission-structures' });
await fastify.register(dataModelRoutes, { prefix: '/data-model' });
await fastify.register(simulationRoutes, { prefix: '/simulations' });
await fastify.register(llmRoutes, { prefix: '/llm' });
await fastify.register(insuranceTypeRoutes, { prefix: '/insurance-types' });
await fastify.register(insurerRoutes, { prefix: '/insurers' });
await fastify.register(pricingRoutes, { prefix: '/pricing' });
await fastify.register(productLibraryRoutes, { prefix: '/product-library' });
await fastify.register(coverageRoutes, { prefix: '/coverages' });
await fastify.register(coveragePricingRoutes);
await fastify.register(auditLogRoutes, { prefix: '/audit-logs' });
await fastify.register(userRoutes, { prefix: '/users' });
await fastify.register(customerRoutes, { prefix: '/customers' });
await fastify.register(permissionsRoutes, { prefix: '/permissions' });
await fastify.register(clientRoutes, { prefix: '/clients' });
await fastify.register(productRoutes, { prefix: '/products' });
await fastify.register(productOptimizationRoutes, { prefix: '/products' });
await fastify.register(productSettingRoutes, { prefix: '/products' });
await fastify.register(policyAcceptanceConfigRoutes, { prefix: '/products' });
await fastify.register(organizationRoutes, { prefix: '/organizations' });
await fastify.register(partnerApiRoutes, { prefix: '/partner-api' });
await fastify.register(locationRoutes, { prefix: '/location' });
await fastify.register(moduleRoutes, { prefix: '/modules' });
await fastify.register(scopeRoutes, { prefix: '/scopes' });
await fastify.register(taxRoutes, { prefix: '/taxes' });
=== how is plus index registered + prefix ===
111:import { plusRoutes } from '@/routes/plus/index.js';
244: await app.register(plusRoutes, { prefix: '/v1/plus' });{"command":"cd /Users/bakker/dev/gangkhar/sherpa-portal\necho \"=== interceptor URL rewrite logic (v1 prefixing) ===\"\nrg -n \"v1|rewrite|legacy|/auth|config.url\" app/src/utils/api.ts | head -40","description":"Portal api path rewrite logic"}=== interceptor URL rewrite logic (v1 prefixing) ===
6:import useAuthStore from "../store/authStore";
15:// INVARIANT: every entry MUST start with `/v1/`. The request interceptor
16:// rewrites legacy `/auth/...` and bare paths into `/v1/...` BEFORE this list
17:// is consulted, so any non-`/v1/` entry here is unreachable and indicates a
20: "/v1/auth/login",
21: "/v1/auth/customer/login",
22: "/v1/auth/register",
23: "/v1/auth/forgot-password",
24: "/v1/auth/refresh-token",
25: "/v1/auth/customer/refresh-token",
26: "/v1/organization",
27: "/v1/health/self",
30:// Fail fast on refactor breakage: surfaces a missing rewrite or a stale entry
33: if (!path.startsWith("/v1/")) {
35: `NO_AUTH_PATHS invariant violated: "${path}" must start with "/v1/". ` +
36: `The request interceptor only sees rewritten /v1/* URLs.`,
120: ? `${getSherpaApiUrl()}/v1/auth/customer/refresh-token`
121: : `${getSherpaApiUrl()}/v1/auth/refresh-token`;
185: const rawUrl = config.url || "/";
187: if (!ensured.startsWith("/v1/")) {
189: if (ensured.startsWith("/auth")) {
190: config.url = `/v1${ensured}`;
192: config.url = isAdmin ? `/v1/plus${ensured}` : `/v1${ensured}`;
195: config.url = ensured;
208: if (isNoAuthPath(config.url)) {
296: // `originalRequest.url` was already rewritten to its `/v1/...` form by
298: // against NO_AUTH_PATHS (all `/v1/`-prefixed by the module-load
323: originalRequest.url?.includes("/auth/organizations/") ||
324: originalRequest.url?.includes("/auth/switch-organization")Resolved: interceptor (api.ts:185-195) rewrites bare /users → /v1/plus/users when isAdmin, else /v1/users. Admin tab is in AdminOnlyRoute → admin → hits GET /v1/plus/users = getAllUsers. Confirmed target.
Final reads: confirm isAdmin logic, portal AGENTS.md conventions, sherpa-test structure/pattern, badge+tooltip APIs.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-portal\necho \"=== api.ts 178-210 (isAdmin + rewrite) ===\"; sed -n '178,210p' app/src/utils/api.ts","description":"Confirm isAdmin rewrite logic"}=== api.ts 178-210 (isAdmin + rewrite) ===
}
return null;
}
private setupInterceptors(): void {
this.api.interceptors.request.use(
async (config: InternalAxiosRequestConfig & { sherpaAdmin?: boolean }) => {
const rawUrl = config.url || "/";
const ensured = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
if (!ensured.startsWith("/v1/")) {
const isAdmin = config.sherpaAdmin !== false;
if (ensured.startsWith("/auth")) {
config.url = `/v1${ensured}`;
} else {
config.url = isAdmin ? `/v1/plus${ensured}` : `/v1${ensured}`;
}
} else {
config.url = ensured;
}
try {
void useAuthStore.getState().accessToken;
} catch {}
const localeValue = this.getLocaleCookie();
if (localeValue) {
config.headers["locale"] = localeValue;
}
// Credential-less routes: don't attach Authorization and don't trigger
// a pre-refresh on expired tokens. A response from these routes is
// never a verdict on the user's session.
if (isNoAuthPath(config.url)) {
return config;
}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/AGENTS.md"}1 # AGENTS.md
2
3 Sherpa Portal -- full-stack insurance platform.
4
5 ## Tech Stack
6
7 - **Frontend**: Next.js 13+ (App Router), TypeScript, Tailwind CSS
8 - **Backend**: Node.js/Express.js, TypeScript
9 - **Database**: PostgreSQL
10 - **Caching**: ValKey (Redis-compatible)
11 - **State Management**: Zustand
12 - **i18n**: LMT (Language Model Translations)
13
14 ## Docker Services
15
16 - `frontend`: Next.js app (port via PORT env var, default 3000)
17 - `backend`: Express.js API on port 4000
18
19 ## Directory Structure
20
21 ```
22 app/ # Next.js 13+ App Router (frontend)
23 ├── components/ # Reusable components
24 │ └── portal-assistant/ # Portal Sherpa AI assistant (see section below)
25 ├── src/
26 │ ├── store/ # Zustand stores (including changeSetStore, draftResourceStore)
27 │ ├── utils/ # API client, env helpers, utilities (getMeshUrl in env.ts)
28 │ └── hooks/ # Custom React hooks
29 ├── types/ # TypeScript type definitions
30 ├── services/ # Frontend service modules
31 └── [feature]/ # Feature pages (dashboard, products, lens, etc.)
32 lib/lmt/ # LMT i18n runtime (t, client, server, config)
33 translations/ # Translation data
34 ├── locales/ # Per-locale JSON (es_419.json, pt_BR.json)
35 ├── generated/ # Auto-generated locale module
36 ├── config.ts # Translation pipeline config
37 ├── keys.json # Extracted key manifest
38 └── cache.json # Pipeline cache
39 scripts/translate.ts # LLM translation pipeline
40 k8s/ # Kubernetes deployment (cdk8s)
41 ```
42
43 ## Dev Commands
44
45 ```bash
46 npm run dev # Start dev server
47 npm run dev:https # Dev with HTTPS (requires SSL certs)
48 npm run build # Production build
49 npm run start # Production server
50 npm run translate # Extract keys / incremental translation pipeline
51 npm run validate-translations # Validate locale coverage and key consistency
52 ```
53
54 ## i18n — Write English, translations are automatic
55
56 Every user-facing string must be wrapped with `t()`. Write in English — translations happen automatically in CI.
57
58 ```tsx
59 import { useTranslation } from "@gangkharai/lmt/client";
60
61 const MyComponent = () => {
62 const t = useTranslation();
63 return <h1>{t('Insurance Products')}</h1>;
64 };
65 ```
66
67 ### Patterns
68
69 ```tsx
70 <button>{t('Save')}</button>
71 <input placeholder={t('Search...')} />
72 showSuccessToast(t('Saved successfully'))
73 t('{count} products selected', { count: selected.length })
74 status === "active" ? t('Active') : t('Inactive')
75 <p>{t('File')}: <strong>{name}</strong> ({count} {t('rows')})</p>
76 ```
77
78 ### What NOT to wrap
79
80 CSS classes, routes, console.log, `throw new Error()`, API paths, object keys, enum identifiers, format strings, dynamic API content.
81
82 ### Validation command
83
84 Run `npm run validate-translations` to validate translation keys/locales and catch invalid `t()` usage early.
85
86 ### AST audit for missing `t()` (non-grep)
87
88 When translation gaps are scattered across many `.tsx` files, run an AST-based audit (TypeScript parser) instead of grep text matching.
89
90 - Parse `app/**/*.tsx` with `typescript` and inspect `JsxText` + string literals in UI contexts (`jsx-text`, user-visible attrs like `placeholder`, `aria-label`, and toast/message calls).
91 - Ignore non-UI values (routes, object keys, API paths, regex patterns, technical identifiers, and format tokens).
92 - Only treat strings as missing when they are **not** already inside `t("...")`.
93 - Fix highest-confidence files first, then rerun the AST audit until no actionable candidates remain.
94 - After each batch: run `npx tsc --noEmit`, lint touched files, then run `npm run validate-translations`.
95
96 ### Translation Key Safety
97
98 - Never call `t()` with variables or expressions like `t(label)`, `t(config.label)`, `t(source.label)`, `t(error)`.
99 - `t()` must receive a literal English string (or a literal template with params): `t('Paid')`, `t('{count} items', { count })`.
100 - If UI labels come from local config arrays, translate at definition time with literal keys, then render the translated value directly.
101 - If a value comes from API/user input, render it as-is (or map known enum values to literal `t('...')` keys), but do not pass raw dynamic values to `t()`.
102 - Keep local label definitions co-located in the component/function using them; do not split into external raw-value constants plus separate translation helper mappers.
103
104 ## CRITICAL: `"use client"` Directive
105
106 **Every `.tsx` component that uses hooks, event handlers, browser APIs, or is imported by a `"use client"` file MUST have `"use client"` at the top.**
107
108 Without it, Next.js treats the file as a server component. In production this causes:
109
110 ```
111 Application error: a client-side exception has occurred
112 ```
113
114 Browser console shows:
115 ```
116 Error: `cookies` was called outside a request scope.
117 ```
118
119 This crashes the entire page. It **only happens in production/deployed builds**, never locally, making it extremely hard to catch before deploy.
120
121 **Rule**: When creating or extracting any `.tsx` component, add `"use client"` unless it is exclusively rendered by server components (root layout, other server components). When in doubt, add it.
122
123 See `.claude/skills/missing-use-client/SKILL.md` for full diagnostic details.
124
125 ## Code Quality
126
127 Run after every change:
128
129 ```bash
130 npx eslint ./app
131 npx tsc
132 npx prettier --check
133 ```
134
135 Fix ALL eslint warnings until none remain, even if unrelated to your changes.
136
137 ## TypeScript
138
139 - All code in TypeScript
140 - Define interfaces for all data structures
141 - **Never use `any`** -- use `unknown` for catch clauses, `Record<string, unknown>` for unknown objects, type guards for narrowing
142 - Always use `@` imports: `import xxx from '@/app/...'`
143
144 ## Environment Variables
145
146 - **Server-side**: Use `getRequired()` from `app/src/utils/env.ts`
147 - **Client-side**: Use `process.env.NEXT_PUBLIC_*` directly (Next.js inlines at build time)
148 - **Never** use `getRequired()` with `NEXT_PUBLIC_*` variables
149 - **Never** use fallback values (`|| 'default'`) -- fail fast on missing config
150
151 ### URL Helpers (from `app/src/utils/env.ts`)
152
153 - `getSherpaApiUrl()` -- API base URL
154 - `getAppUrl()` -- Application URLs (redirects, links)
155 - `getEmbedUrl()` -- Embed widget URLs
156
157 These auto-detect HTTP/HTTPS based on environment and protocol.
158
159 ### Deprecated (DO NOT USE)
160
161 - `NEXT_PUBLIC_SHERPA_API` → `getSherpaApiUrl()`
162 - `NEXT_PUBLIC_APP_URL` → `getAppUrl()`
163 - `NEXT_PUBLIC_EMBED_URL` → `getEmbedUrl()`
164 - `NEXT_PUBLIC_USE_HTTPS` → auto-detected
165 - `NEXT_PUBLIC_BACKEND_PORT` → included in URL variables
166
167 ## Dev flags
168
169 Dev flags exist to **hide** features from non-developers. When a flag is
170 off, the gated feature must be invisible — no message, no hint that a
171 hidden surface exists, no breadcrumb pointing at `/dev/flags`.
172
173 Gate at the **layout** of the route subtree, not in each `page.tsx`.
174 A client `layout.tsx` reads the flag, `router.push("/")`, and returns
175 `null` when off; every page under that layout drops the flag check
176 entirely. Server middleware is the wrong tool here — dev flags live in
177 `localStorage` and middleware can't see it.
178
179 Canonical examples: `app/mcp/layout.tsx`, `app/lens/synthetic-client/layout.tsx`.
180 Never render a "this feature is behind a dev flag, enable it on …"
181 panel — that defeats the entire point of the flag.
182
183 ## Portal Sherpa (AI Assistant)
184
185 The Portal Sherpa is a floating sidebar assistant embedded in the a{"command":"cd /Users/bakker/dev/gangkhar/sherpa-test\necho \"=== structure ===\"; fd -t d -d 2 . 2>/dev/null | head; echo \"--- spec count + names ---\"; fd -e spec.ts . tests 2>/dev/null | head -30\necho \"=== representative spec head (any admin/login fixture) ===\"; sed -n '1,40p' tests/session-intent-capture.spec.ts 2>/dev/null","description":"sherpa-test structure + spec pattern"}=== structure ===
e2e/
playwright/
tests/
tests/flows/
tests/multi-currency/
tests/payments/
tests/plus-insurance/
tests/utils/
tests/widget/
--- spec count + names ---
tests/api-outage-banner.spec.ts
tests/customer-auth.spec.ts
tests/customer-portal-typed-signal.spec.ts
tests/insurance-product.spec.ts
tests/multi-currency/00-reset.spec.ts
tests/multi-currency/01-multi-currency.spec.ts
tests/organization-public-session.spec.ts
tests/payments/00-reset-payments-organization.spec.ts
tests/payments/00-stripe-payment-flow.spec.ts
tests/payments/01-embed-checkout-flow.spec.ts
tests/plus-insurance/00-reset-organization.spec.ts
tests/plus-insurance/01-product-setup.spec.ts
tests/plus-insurance/02-bulk-policy-creation.spec.ts
tests/plus-insurance/02-product-unhappy-paths.spec.ts
tests/plus-insurance/04-quote-policy-flow.spec.ts
tests/plus-insurance/05-policy-orders-and-financials.spec.ts
tests/plus-insurance/06-policy-status-guards-and-recovery.spec.ts
tests/plus-insurance/07-audit-log.spec.ts
tests/plus-insurance/08-compliance-checks.spec.ts
tests/security-frontend.spec.ts
tests/security.spec.ts
tests/session-credential-rejection.spec.ts
tests/session-intent-capture.spec.ts
tests/wayni.spec.ts
tests/widget/widget-token-embed.spec.ts
=== representative spec head (any admin/login fixture) ===
import { test, expect, type Page } from '@playwright/test';
import { getPortalUrl } from './utils/urls';
import {
INTENDED_PATH_KEY,
clearPortalSession,
readIntendedPath,
writeIntendedPath,
} from './utils/session-state';
// Portal stores intended return URL in sessionStorage when auth gate sends you to login,
// clears it on explicit logout, and defines bounce behavior on /login for logged-in users.
// Assertions use storage + URLs instead of replaying OAuth.
test.describe.configure({ timeout: 60_000 });
const DEEP_PROTECTED_PATH = '/partners';
const ADMIN_DEFAULT_PATH = '/products';
async function waitForLoginUrl(page: Page): Promise<void> {
await page.waitForURL(/\/login(?:[/?#]|$)/, {
timeout: 30_000,
waitUntil: 'domcontentloaded',
});
}
test.describe('Intent capture when visitor hits a protected route without session', () => {
test('logged-out visitor to a protected deep path lands on /login with intendedPath captured', async ({ page }) => {
const portalUrl = getPortalUrl();
await page.goto(`${portalUrl}/login`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await clearPortalSession(page);
await page.goto(`${portalUrl}${DEEP_PROTECTED_PATH}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await waitForLoginUrl(page);
await expect{"command":"cd /Users/bakker/dev/gangkhar/sherpa-portal\necho \"=== badge.tsx ===\"; cat app/components/ui/badge.tsx 2>/dev/null | head -45\necho \"=== tooltip.tsx (exports) ===\"; rg -n \"export\" app/components/ui/tooltip.tsx | head","description":"badge + tooltip primitive APIs"}=== badge.tsx ===
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/app/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
// Badge UI component - no translatable strings
=== tooltip.tsx (exports) ===
69:export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };Everything resolved. Confirmed: admin tab → isAdmin default true (api.ts:184 config.sherpaAdmin !== false) → GET /v1/plus/users. Portal has badge.tsx + tooltip.tsx for overflow rendering. No existing test covers admin users table (Playwright e2e only, fixtures in sherpa-test/tests/utils). Conventions: i18n t() mandatory, "use client", no any.
Writing report now.
{"command":"mkdir -p /tmp/ticket-automation-logs/drafts && echo \"dir ready\"","description":"Ensure output dir exists"}dir ready
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md","content":"# Codebase research: Admin Users table (organizations, active status, status filter)\n\nFeature target: sherpa-portal `/admin?tab=users`. Three asks: (1) show a user's active organizations in the table (bug: nothing shows today), (2) show whether the user is active or inactive, (3) add an active/inactive filter above the table.\n\n## Summary\n\nThe Users table screen already exists and most of the data model is in place, so this is a moderate-sized full-stack change rather than greenfield work. The \"organizations not displayed\" bug is real and has a clear root cause: the table renders `getOrganizationName(user.organizationId)` (a single legacy field), but the `User` model no longer has an `organizationId` column (organization membership moved to a `UserOrganization` join table), so that cell is always blank. Worse, the list API (`GET /v1/plus/users`, `getAllUsers`) never includes the `organizations` association in its payload, so the data needed to fix the cell is not even returned today. The detail screen (`UserDetail.tsx`) already renders the full multi-org membership list with active/inactive badges, so the rendering pattern and the `UserData.organizations` type already exist and can be reused. User-level \"active vs inactive\" maps to the existing `deleted` boolean (the detail page already labels `!deleted` as \"Account Status: Active/Inactive\"), but the list API hardcodes `deleted: false`, so today every listed user is active and there is no way to list or filter inactive users. The active/inactive filter requires a new querystring param on the list endpoint plus a `Select` control in the table header (the organization `Select` filter directly above it is the pattern to copy).\n\n## What exists today\n\n- Admin page and tab routing: `sherpa-portal/app/admin/page.tsx:30-50`. `activeTab === \"users\"` renders `<UsersTab />` with no props, wrapped in `<AdminOnlyRoute>` (`page.tsx:35`, `page.tsx:38`).\n- The Users table component: `sherpa-portal/app/components/admin/UsersTab.tsx` (whole file). Columns rendered today: Name, Email, Role, Organization, Created At, 2FA Status, Actions (`UsersTab.tsx:347-398`).\n- The detail screen that already does multi-org + active rendering: `sherpa-portal/app/components/users/UserDetail.tsx:344-395` (org membership cards with `org.UserOrganization.isActive` Active/Inactive badge) and `UserDetail.tsx:300-320` (Account Status toggle bound to `userData.deleted`).\n- List API handler: `sherpa-api/src/controllers/userController.ts:73-194` (`getAllUsers`).\n- Detail API handler (already returns orgs): `sherpa-api/src/controllers/userController.ts:196-290` (`getUserById`).\n- Data model: `sherpa-api/src/models/User.ts` (no `organizationId`, no `active`; has `deleted` at `User.ts:163-167`), `sherpa-api/src/models/internal/_UserOrganization.ts` (join table with `isActive`, `User.ts:57-62` of that file).\n\n### The \"organizations not displayed\" bug, grounded\n\n1. The table cell reads a single legacy field: `UsersTab.tsx:433-435` renders `{getOrganizationName(user.organizationId)}`, and `getOrganizationName` (`UsersTab.tsx:121-125`) looks up `organizationId` in a separately fetched `/organizations` list.\n2. `user.organizationId` is effectively always undefined. The `User` Sequelize model (`sherpa-api/src/models/User.ts`, read in full) has no `organizationId` column. Membership now lives in the `UserOrganization` join table (`User.ts:240-250` define the `userOrganizations` HasMany and `organizations` BelongsToMany associations). A legacy migration `sherpa-api/src/db/migrations/2025.07.20T00.01.16.add-organization-id-to-users.ts` exists, but later migrations created and populated the join table (`2025.09.19T00.00.01.create-user-organizations.ts`, `2025.09.19T00.00.02.populate-user-organizations.ts`). Net effect: `getOrganizationName(undefined)` returns `\"\"`, so the Organization column is blank for every row. This matches the report \"this is currently a bug that it is not displayed at all.\"\n3. The list API does not return the data needed to fix it. `getAllUsers` builds `queryOptions` with `attributes.exclude` only (`userController.ts:150-158`) and adds an `include` of `UserOrganization` ONLY when an `organizationId` filter is supplied, and even then with `attributes: []` and used purely as a join filter (`userController.ts:162-175`). The `organizations` association is never eager-loaded, so `user.organizations` is absent from the list response. Contrast with the detail handler, which does include it: `userController.ts:221-229`.\n\n### User active/inactive, grounded\n\n- There is no `active`/`isActive`/`status` column on the `User` model. The only soft-delete/active flag is `deleted: boolean` (`sherpa-api/src/models/User.ts:163-167`, default `false`).\n- The established UI convention is `!deleted` = Active: `UserDetail.tsx:300-320` renders \"Account Status\" as `userData.deleted ? t(\"Inactive\") : t(\"Active\")` and toggles `deleted` on change.\n- The list endpoint hardcodes `whereClause = { deleted: false }` (`userController.ts:122-124`), so inactive (soft-deleted) users are never returned. The table also re-filters client-side: `UsersTab.tsx:141` `.filter((user) => !user.deleted)`.\n- Caveat to respect: soft-delete mangles the email and azure id (`userController.ts:400-411`: sets `email = ${email}_deleted_${id}_${Date.now()}`). So if \"inactive\" is taken to mean `deleted === true`, the listed rows would show mangled emails. This is a real product decision the ticket must resolve (see Risks).\n- Note an inconsistency: `updateUserBodySchema` already declares an `active` boolean (`sherpa-api/src/schemas/plus/user.schemas.ts:62`) that has no backing column on `User`. It is effectively a no-op today. The ticket should either wire a real `active` column or standardize on `!deleted`; do not leave both.\n\n### Active organizations, grounded\n\n- \"Active organizations of a us<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
File exists. Read then overwrite.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md"}1 (codebase research produced no report) 2
Placeholder. Overwrite.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md","content":"# Codebase research: Admin Users table (organizations, active status, status filter)\n\nFeature target: sherpa-portal `/admin?tab=users`. Three asks: (1) show a user's active organizations in the table (bug: nothing shows today), (2) show whether the user is active or inactive, (3) add an active/inactive filter above the table.\n\n## Summary\n\nThe Users table screen already exists and most of the data model is in place, so this is a moderate-sized full-stack change rather than greenfield work. The \"organizations not displayed\" bug is real and has a clear root cause: the table renders `getOrganizationName(user.organizationId)` (a single legacy field), but the `User` model no longer has an `organizationId` column (organization membership moved to a `UserOrganization` join table), so that cell is always blank. Worse, the list API (`GET /v1/plus/users`, `getAllUsers`) never includes the `organizations` association in its payload, so the data needed to fix the cell is not even returned today. The detail screen (`UserDetail.tsx`) already renders the full multi-org membership list with active/inactive badges, so the rendering pattern and the `UserData.organizations` type already exist and can be reused. User-level \"active vs inactive\" maps to the existing `deleted` boolean (the detail page already labels `!deleted` as \"Account Status: Active/Inactive\"), but the list API hardcodes `deleted: false`, so today every listed user is active and there is no way to list or filter inactive users. The active/inactive filter requires a new querystring param on the list endpoint plus a `Select` control in the table header (the organization `Select` filter directly above it is the pattern to copy).\n\n## What exists today\n\n- Admin page and tab routing: `sherpa-portal/app/admin/page.tsx:30-50`. `activeTab === \"users\"` renders `<UsersTab />` with no props, wrapped in `<AdminOnlyRoute>` (`page.tsx:35`, `page.tsx:38`).\n- The Users table component: `sherpa-portal/app/components/admin/UsersTab.tsx` (whole file). Columns rendered today: Name, Email, Role, Organization, Created At, 2FA Status, Actions (`UsersTab.tsx:347-398`).\n- The detail screen that already does multi-org + active rendering: `sherpa-portal/app/components/users/UserDetail.tsx:344-395` (org membership cards with `org.UserOrganization.isActive` Active/Inactive badge) and `UserDetail.tsx:300-320` (Account Status toggle bound to `userData.deleted`).\n- List API handler: `sherpa-api/src/controllers/userController.ts:73-194` (`getAllUsers`).\n- Detail API handler (already returns orgs): `sherpa-api/src/controllers/userController.ts:196-290` (`getUserById`).\n- Data model: `sherpa-api/src/models/User.ts` (no `organizationId`, no `active`; has `deleted` at `User.ts:163-167`), `sherpa-api/src/models/internal/_UserOrganization.ts` (join table with `isActive` at `:57-62`).\n\n### The \"organizations not displayed\" bug, grounded\n\n1. The table cell reads a single legacy field: `UsersTab.tsx:433-435` renders `{getOrganizationName(user.organizationId)}`, and `getOrganizationName` (`UsersTab.tsx:121-125`) looks up `organizationId` in a separately fetched `/organizations` list.\n2. `user.organizationId` is effectively always undefined. The `User` Sequelize model (`sherpa-api/src/models/User.ts`, read in full) has no `organizationId` column. Membership now lives in the `UserOrganization` join table (`User.ts:240-250` define the `userOrganizations` HasMany and `organizations` BelongsToMany associations). A legacy migration `sherpa-api/src/db/migrations/2025.07.20T00.01.16.add-organization-id-to-users.ts` exists, but later migrations created and populated the join table (`2025.09.19T00.00.01.create-user-organizations.ts`, `2025.09.19T00.00.02.populate-user-organizations.ts`). Net effect: `getOrganizationName(undefined)` returns `\"\"`, so the Organization column is blank for every row. This matches the report \"this is currently a bug that it is not displayed at all.\"\n3. The list API does not return the data needed to fix it. `getAllUsers` builds `queryOptions` with `attributes.exclude` only (`userController.ts:150-158`) and adds an `include` of `UserOrganization` ONLY when an `organizationId` filter is supplied, and even then with `attributes: []` used purely as a join filter (`userController.ts:162-175`). The `organizations` association is never eager-loaded, so `user.organizations` is absent from the list response. Contrast with the detail handler, which does include it: `userController.ts:221-229`.\n\n### User active/inactive, grounded\n\n- There is no `active`/`isActive`/`status` column on the `User` model. The only soft-delete/active flag is `deleted: boolean` (`sherpa-api/src/models/User.ts:163-167`, default `false`).\n- The established UI convention is `!deleted` = Active: `UserDetail.tsx:300-320` renders \"Account Status\" as `userData.deleted ? t(\"Inactive\") : t(\"Active\")` and toggles `deleted` on change.\n- The list endpoint hardcodes `whereClause = { deleted: false }` (`userController.ts:122-124`), so inactive (soft-deleted) users are never returned. The table also re-filters client-side: `UsersTab.tsx:141` `.filter((user) => !user.deleted)`.\n- Caveat to respect: soft-delete mangles the email and azure id (`userController.ts:400-411`: sets `email = ${email}_deleted_${id}_${Date.now()}`). So if \"inactive\" is taken to mean `deleted === true`, listed rows would show mangled emails. This is a real product decision the ticket must resolve (see Risks).\n- Inconsistency to note: `updateUserBodySchema` already declares an `active` boolean (`sherpa-api/src/schemas/plus/user.schemas.ts:62`) that has no backing column on `User`. It is effectively a no-op today. The ticket should either wire a real `active` column or standardize on `!deleted`; do not leave both.\n\n### Active organizations, grounded\n\n- \"Active organizations of a user\" = `Organization` rowsThe file /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md has been updated successfully. (file state is current in your context — no need to Read it back)
Report written to /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md.
Key findings: bug root-caused (table reads dead user.organizationId; model dropped that column, org moved to UserOrganization join table; list API getAllUsers never includes organizations). User active = !deleted (existing convention, list hardcodes deleted:false). Target endpoint GET /v1/plus/users. Filter + multi-org rendering patterns already exist in-repo to copy.
Report written to /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md.
Key findings: bug root-caused (table reads dead user.organizationId; model dropped that column, org moved to UserOrganization join table; list API getAllUsers never includes organizations). User active = !deleted (existing convention, list hardcodes deleted:false). Target endpoint GET /v1/plus/users. Filter + multi-org rendering patterns already exist in-repo to copy.
Caveman mode on. Task = browser research. Report write normal prose.
Start recon. Parallel checks.
{"command":"ls -la /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof 2>&1; echo \"---AUTH---\"; ls -la /Users/bakker/dev/gangkhar/sherpa-test/playwright/.auth/user.json 2>&1; echo \"---PORTAL---\"; curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000 2>&1; echo \" portal\"; echo \"---EMBED---\"; curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3100 2>&1; echo \" embed\"","description":"Check proof dir, auth state, portal/embed reachability"}total 0 drwxr-xr-x@ 2 bakker wheel 64 Jun 2 13:20 . drwxr-xr-x@ 12 bakker wheel 384 Jun 2 13:20 .. ---AUTH--- -rw-r--r--@ 1 bakker staff 13813 Apr 28 13:45 /Users/bakker/dev/gangkhar/sherpa-test/playwright/.auth/user.json ---PORTAL--- 200 portal ---EMBED--- 200 embed
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-test && ls -la && echo \"---PKG---\" && cat package.json 2>&1 | head -60","description":"Inspect sherpa-test repo layout and package.json"}total 144
drwxr-xr-x@ 19 bakker staff 608 Jun 1 17:55 .
drwxr-xr-x 83 bakker staff 2656 Jun 2 11:26 ..
drwxr-xr-x@ 3 bakker staff 96 Apr 9 14:32 .claude
drwxr-xr-x@ 3 bakker staff 96 Apr 6 13:00 .cursor
-rw-r--r--@ 1 bakker staff 999 Apr 24 10:30 .env
-rw-r--r--@ 1 bakker staff 1009 Apr 6 13:00 .env.reference
drwxr-xr-x@ 16 bakker staff 512 Jun 2 07:24 .git
-rw-r--r--@ 1 bakker staff 326 Apr 17 11:24 .gitignore
-rw-r--r--@ 1 bakker staff 4825 Apr 6 13:00 CLAUDE.md
-rw-r--r--@ 1 bakker staff 9577 Apr 30 11:33 README.md
drwxr-xr-x@ 2 bakker staff 64 Apr 17 11:23 e2e
drwxr-xr-x@ 62 bakker staff 1984 Apr 17 11:14 node_modules
-rw-r--r--@ 1 bakker staff 27202 Apr 19 21:29 package-lock.json
-rw-r--r--@ 1 bakker staff 4581 May 18 17:02 package.json
drwxr-xr-x@ 3 bakker staff 96 Apr 17 11:19 playwright
drwxr-xr-x@ 5 bakker staff 160 May 21 13:44 playwright-report
-rw-r--r--@ 1 bakker staff 3701 May 18 17:01 playwright.config.ts
drwxr-xr-x@ 27 bakker staff 864 May 21 13:44 test-results
drwxr-xr-x@ 19 bakker staff 608 May 18 16:59 tests
---PKG---
{
"name": "sherpa-test",
"version": "1.0.0",
"description": "Playwright Testing",
"main": "index.js",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --headed --debug --workers=1 --project=chromium",
"test:chromium": "playwright test --project=chromium",
"test:chromium:headed": "playwright test --project=chromium --headed",
"test:firefox": "playwright test --project=firefox",
"test:firefox:headed": "playwright test --project=firefox --headed",
"test:webkit": "playwright test --project=webkit",
"test:webkit:headed": "playwright test --project=webkit --headed",
"test:plus": "playwright test --project=plus-insurance",
"test:plus:list": "playwright test --project=plus-insurance --reporter=list",
"test:payments": "playwright test --project=payments --reporter=list",
"test:security": "playwright test --project=api && npm run report",
"test:security:local": "ENV=local playwright test --project=api && npm run report",
"test:security:staging": "ENV=staging playwright test --project=api && npm run report",
"test:security:prod": "ENV=production playwright test --project=api && npm run report",
"test:api": "playwright test --project=plus-insurance --project=payments --project=multi-currency --project=api --project=frontend-security",
"test:local": "ENV=local playwright test",
"test:local:headed": "ENV=local playwright test --headed",
"test:local:chromium": "ENV=local playwright test --project=chromium",
"test:local:chromium:headed": "ENV=local playwright test --project=chromium --headed",
"test:local:firefox": "ENV=local playwright test --project=firefox",
"test:local:firefox:headed": "ENV=local playwright test --project=firefox --headed",
"test:local:webkit": "ENV=local playwright test --project=webkit",
"test:local:webkit:headed": "ENV=local playwright test --project=webkit --headed",
"test:dev": "ENV=dev playwright test",
"test:dev:headed": "ENV=dev playwright test --headed",
"test:dev:chromium": "ENV=dev playwright test --project=chromium",
"test:dev:chromium:headed": "ENV=dev playwright test --project=chromium --headed",
"test:dev:firefox": "ENV=dev playwright test --project=firefox",
"test:dev:firefox:headed": "ENV=dev playwright test --project=firefox --headed",
"test:dev:webkit": "ENV=dev playwright test --project=webkit",
"test:dev:webkit:headed": "ENV=dev playwright test --project=webkit --headed",
"test:staging": "ENV=staging playwright test",
"test:staging:headed": "ENV=staging playwright test --headed",
"test:staging:chromium": "ENV=staging playwright test --project=chromium",
"test:staging:chromium:headed": "ENV=staging playwright test --project=chromium --headed",
"test:staging:firefox": "ENV=staging playwright test --project=firefox",
"test:staging:firefox:headed": "ENV=staging playwright test --project=firefox --headed",
"test:staging:webkit": "ENV=staging playwright test --project=webkit",
"test:staging:webkit:headed": "ENV=staging playwright test --project=webkit --headed",
"test:prod": "ENV=production playwright test",
"test:prod:headed": "ENV=production playwright test --headed",
"test:prod:chromium": "ENV=production playwright test --project=chromium",
"test:prod:chromium:headed": "ENV=production playwright test --project=chromium --headed",
"test:prod:firefox": "ENV=production playwright test --project=firefox",
"test:prod:firefox:headed": "ENV=production playwright test --project=firefox --headed",
"test:prod:webkit": "ENV=production playwright test --project=webkit",
"test:prod:webkit:headed": "ENV=production playwright test --project=webkit --headed",
"test:all": "npm run test:local && npm run test:dev && npm run test:staging && npm run test:prod",
"report": "playwright show-report",
"postinstall": "npm rebuild better-sqlite3"
},Recon good: proof dir exists (empty), auth file present, portal+embed both 200. Playwright installed in sherpa-test.
Check config + helpers for reuse.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-test/playwright.config.ts"}1 import { defineConfig, devices } from '@playwright/test';
2 import dotenv from 'dotenv';
3 import path from 'path';
4
5 dotenv.config({ path: path.resolve(__dirname, '.env') });
6
7 /**
8 * See https://playwright.dev/docs/test-configuration.
9 */
10 export default defineConfig({
11 testDir: './tests',
12 fullyParallel: false,
13 forbidOnly: !!process.env.CI,
14 retries: process.env.CI ? 2 : 0,
15 workers: 3,
16 /* Reporter to use. See https://playwright.dev/docs/test-reporters */
17 reporter: 'html',
18 /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
19 use: {
20 /* Base URL to use in actions like `await page.goto('')`. */
21 // baseURL: 'http://localhost:3000',
22
23 /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
24 trace: 'on-first-retry',
25 screenshot: 'only-on-failure',
26 },
27
28 /* Configure projects for major browsers */
29 projects: [
30 {
31 name: 'plus-insurance',
32 testMatch: /plus-insurance\/.*\.spec\.ts/,
33 use: {},
34 fullyParallel: false,
35 workers: 1,
36 },
37 {
38 name: 'payments',
39 testMatch: /payments\/.*\.spec\.ts/,
40 use: {},
41 fullyParallel: false,
42 workers: 1,
43 },
44 {
45 name: 'multi-currency',
46 testMatch: /multi-currency\/.*\.spec\.ts/,
47 use: {},
48 fullyParallel: false,
49 workers: 1,
50 },
51 {
52 name: 'widget',
53 testMatch: /widget\/.*\.spec\.ts/,
54 use: {},
55 fullyParallel: false,
56 workers: 1,
57 },
58 {
59 name: 'api',
60 testMatch: /security\.spec\.ts/,
61 use: {},
62 },
63 {
64 name: 'customer-auth',
65 testMatch: /customer-auth\.spec\.ts/,
66 use: {},
67 dependencies: ['plus-insurance'],
68 },
69 {
70 name: 'frontend-security',
71 testMatch: /security-frontend\.spec\.ts/,
72 use: {},
73 },
74 {
75 name: 'setup',
76 testMatch: /.*\.setup\.ts/,
77 timeout: 120_000,
78 use: {
79 headless: false,
80 },
81 },
82 {
83 name: 'chromium',
84 testMatch: /.*\.spec\.ts/,
85 testIgnore: [/security\.spec\.ts/, /customer-auth\.spec\.ts/, /plus-insurance\//, /payments\//, /widget\//],
86 use: {
87 ...devices['Desktop Chrome'],
88 storageState: 'playwright/.auth/user.json',
89 },
90 dependencies: ['setup'],
91 },
92
93 {
94 name: 'firefox',
95 testMatch: /.*\.spec\.ts/,
96 testIgnore: [/security\.spec\.ts/, /customer-auth\.spec\.ts/, /plus-insurance\//, /payments\//, /widget\//],
97 use: {
98 ...devices['Desktop Firefox'],
99 storageState: 'playwright/.auth/user.json',
100 },
101 dependencies: ['setup'],
102 },
103
104 {
105 name: 'webkit',
106 testMatch: /.*\.spec\.ts/,
107 testIgnore: [/security\.spec\.ts/, /customer-auth\.spec\.ts/, /plus-insurance\//, /payments\//, /widget\//],
108 use: {
109 ...devices['Desktop Safari'],
110 storageState: 'playwright/.auth/user.json',
111 },
112 dependencies: ['setup'],
113 },
114
115 /* Test against mobile viewports. */
116 // {
117 // name: 'Mobile Chrome',
118 // use: { ...devices['Pixel 5'] },
119 // },
120 // {
121 // name: 'Mobile Safari',
122 // use: { ...devices['iPhone 12'] },
123 // },
124
125 /* Test against branded browsers. */
126 // {
127 // name: 'Microsoft Edge',
128 // use: { ...devices['Desktop Edge'], channel: 'msedge' },
129 // },
130 // {
131 // name: 'Google Chrome',
132 // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
133 // },
134 ],
135
136 /* Run your local dev server before starting the tests */
137 // webServer: {
138 // command: 'npm run start',
139 // url: 'http://localhost:3000',
140 // reuseExistingServer: !process.env.CI,
141 // },
142 });
143 {"command":"echo \"---PLAYWRIGHT DIR---\" && find playwright -type f | head -40; echo \"---TESTS DIR---\" && ls tests; echo \"---ADMIN TESTS---\" && rg -l -i \"admin|tab=users|users\" tests 2>/dev/null | head -20","description":"Inspect playwright helpers, tests dir, admin-related tests"}---PLAYWRIGHT DIR--- playwright/.auth/user.json ---TESTS DIR--- api-outage-banner.spec.ts auth.setup.ts customer-auth.spec.ts customer-portal-typed-signal.spec.ts flows insurance-product.spec.ts multi-currency organization-public-session.spec.ts payments plus-insurance security-frontend.spec.ts security.spec.ts session-credential-rejection.spec.ts session-intent-capture.spec.ts utils wayni.spec.ts widget ---ADMIN TESTS--- tests/session-credential-rejection.spec.ts tests/session-intent-capture.spec.ts tests/multi-currency/01-multi-currency.spec.ts tests/customer-auth.spec.ts tests/plus-insurance/02-bulk-policy-creation.spec.ts tests/api-outage-banner.spec.ts
Auth via storageState. Check auth.setup + utils for URL patterns + confirm chromium binary.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-test/tests/auth.setup.ts"}1 import { test as setup } from '@playwright/test';
2 import { getPortalUrl } from './utils/urls';
3 import * as fs from 'fs';
4
5 const authFile = 'playwright/.auth/user.json';
6
7 setup('authenticate', async ({ page, context }) => {
8 const portalUrl = getPortalUrl();
9 const testEmail = process.env.TEST_USER_EMAIL || '';
10 const testPassword = process.env.TEST_USER_PASSWORD || '';
11
12 // Load existing storage state if it exists (to reuse Microsoft persistent cookies)
13 if (fs.existsSync(authFile)) {
14 const storageState = JSON.parse(fs.readFileSync(authFile, 'utf-8'));
15 await context.addCookies(storageState.cookies || []);
16 }
17
18 await page.goto(portalUrl);
19
20 // Always click login to trigger OAuth flow and get fresh app tokens
21 // Microsoft cookies will allow us to skip MFA, but we need the OAuth redirect for app tokens
22 await page.getByRole('button', { name: /log in with microsoft entra id/i }).click();
23 await page.waitForURL(/login\.microsoftonline\.com/);
24
25 // Check if "Pick an account" page is shown (user already signed in via cookies)
26 const accountTile = page.getByText(testEmail).first();
27 const emailInput = page.getByPlaceholder(/email, phone, or skype/i);
28
29 // Wait for either account picker or email input
30 await Promise.race([
31 accountTile.waitFor({ timeout: 5000 }).catch(() => {}),
32 emailInput.waitFor({ timeout: 5000 }).catch(() => {})
33 ]);
34
35 if (await accountTile.isVisible().catch(() => false)) {
36 // Click the existing account tile to continue
37 console.log('Account picker detected, clicking existing account');
38 await accountTile.click();
39 } else {
40 // Fresh login flow
41 await emailInput.fill(testEmail);
42 await page.getByRole('button', { name: /next/i }).click();
43
44 await page.getByPlaceholder(/password/i).waitFor({ timeout: 10000 });
45 await page.getByPlaceholder(/password/i).fill(testPassword);
46 await page.getByRole('button', { name: /sign in/i }).click();
47 }
48
49 const maxAttempts = 100;
50 let attempt = 0;
51
52 while (attempt < maxAttempts) {
53 await page.waitForTimeout(500);
54 attempt++;
55
56 // Check for successful login FIRST - before looking for Microsoft flow buttons
57 // This prevents clicking pagination "Next" buttons on the portal
58 const currentUrl = page.url();
59 if (currentUrl.includes(portalUrl) && !currentUrl.includes('/login')) {
60 // Wait for the app to fully load user data before saving state
61 // The email being visible indicates the user object is populated
62 const userEmail = page.getByText(testEmail);
63 if (await userEmail.isVisible({ timeout: 1000 }).catch(() => false)) {
64 console.log('Login successful, saving storage state');
65 await page.context().storageState({ path: authFile });
66 return;
67 }
68 }
69
70 // Only look for Microsoft login flow buttons if we're still on Microsoft's domain
71 if (!currentUrl.includes('microsoftonline.com')) {
72 continue;
73 }
74
75 const passwordInput = page.getByPlaceholder(/password/i);
76 if (await passwordInput.isVisible().catch(() => false)) {
77 console.log('Password prompt detected, entering password');
78 await passwordInput.fill(testPassword);
79 await page.getByRole('button', { name: /sign in/i }).click();
80 continue;
81 }
82
83 const nextButton = page.getByRole('button', { name: /next/i });
84 const yesButton = page.getByRole('button', { name: /yes/i });
85 const doneButton = page.getByRole('button', { name: /done/i });
86
87 if (await nextButton.isVisible().catch(() => false)) {
88 await nextButton.click();
89 continue;
90 }
91
92 if (await yesButton.isVisible().catch(() => false)) {
93 const staySignedInCheckbox = page.getByRole('checkbox', { name: /don't show this again/i });
94 if (await staySignedInCheckbox.isVisible().catch(() => false)) {
95 await staySignedInCheckbox.check();
96 }
97 await yesButton.click();
98 continue;
99 }
100
101 if (await doneButton.isVisible().catch(() => false)) {
102 await doneButton.click();
103 continue;
104 }
105 }
106
107 throw new Error('Login did not complete within 50 seconds');
108 });
109
110 {"command":"echo \"---ENV---\" && cat .env 2>&1 | rg -iv \"secret|password|token|key\" | head -30; echo \"---UTILS---\" && ls tests/utils 2>&1; echo \"---CHROMIUM---\" && ls ~/Library/Caches/ms-playwright 2>&1 | head; echo \"---PW VERSION---\" && node -e \"console.log(require('./node_modules/@playwright/test/package.json').version)\" 2>&1","description":"Read auth setup, env (filtered), utils, chromium binary"}---ENV---
DEVELOPER_NAME=chris
ENV=local
SHERPA_PORTAL_HTTP=http://localhost:3000
SHERPA_PORTAL_HTTPS=https://portal.${DEVELOPER_NAME}.dev.gangkhar.dev
SHERPA_API_HTTP=http://localhost:4000
SHERPA_API_HTTPS=https://api.${DEVELOPER_NAME}.dev.gangkhar.dev
SHERPA_PORTAL_DEV_HTTPS=https://portal.dev.gangkhar.dev
SHERPA_API_DEV_HTTPS=https://sherpa-api.dev.gangkhar.dev
SHERPA_PORTAL_STAGING_HTTPS=https://portal.staging.gangkhar.dev
SHERPA_API_STAGING_HTTPS=https://sherpa-api.staging.gangkhar.dev
SHERPA_PORTAL_PROD_HTTPS=https://portal.your-domain.com
SHERPA_API_PROD_HTTPS=https://api.your-domain.com
WAYNI_PORTAL_URL=https://portal.wayni.${DEVELOPER_NAME}.dev.gangkhar.dev
WAYNI_PORTAL_DEV_URL=https://portal.wayni.dev.gangkhar.dev
WAYNI_PORTAL_STAGING_URL=https://portal.wayni.staging.gangkhar.dev
[email protected]
SHERPA_TEST_CLIENT_ID=cl_faf23d26-93db-4a72-af6f-462dff015c96
---UTILS---
mock-organization-resolution.ts
session-state.ts
urls.ts
---CHROMIUM---
chromium-1217
chromium_headless_shell-1217
ffmpeg-1011
firefox-1511
webkit-2272
---PW VERSION---
1.59.1All ready. Auth state pre-saved with tokens, can navigate direct. Portal=3000, feature at /admin?tab=users.
Peek portal admin source to know DOM structure for reliable waits/selectors.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-portal && echo \"---ADMIN FILES---\" && fd -t f -i admin src app 2>/dev/null | head -40; echo \"---USERS REFERENCES---\" && rg -l -i \"tab=users|UsersTab|usersTab|UserTable|users.*table\" src app 2>/dev/null | head -20","description":"Locate portal admin/users page source files"}---ADMIN FILES--- app/components/AdminOnlyRoute.tsx app/types/admin.ts ---USERS REFERENCES--- app/users/page.tsx app/admin/page.tsx app/customers/page.tsx app/components/AppLayout.tsx app/components/admin/UsersTab.tsx
Found source. Read admin page + UsersTab + types to ground observations.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/UsersTab.tsx"}1 "use client";
2
3 import React, { useState, useEffect, useCallback } from "react";
4
5 import { useRouter } from "next/navigation";
6
7 import { ArrowUpDown, Search, X, PlusCircle, Trash2 } from "lucide-react";
8
9 import { useTranslation } from "@gangkharai/lmt/client";
10
11 import Pagination from "@/app/components/Pagination";
12 import { useDebounce } from "@/app/hooks/useDebounce";
13 import api from "@/app/src/utils/api";
14 import { toTitleCase } from "@/app/utils/stringUtils";
15 import { UserData } from "@/app/types/user";
16 import {
17 Select,
18 SelectContent,
19 SelectItem,
20 SelectTrigger,
21 SelectValue,
22 } from "@/app/components/ui/select";
23 import { Organization } from "@/app/types/admin";
24 import { Button } from "@/app/components/ui/button";
25 import UserCreateForm, { UserCreatedData } from "./UserCreateForm";
26 import UserCreatedSuccess from "./UserCreatedSuccess";
27 import {
28 Dialog,
29 DialogContent,
30 DialogDescription,
31 DialogFooter,
32 DialogHeader,
33 DialogTitle,
34 } from "@/app/components/ui/dialog";
35 import { showSuccessToast, showErrorToast } from "@/app/components/Toast";
36 import { useOrganization } from "@/app/components/OrganizationProvider";
37 import useAuthStore from "@/app/src/store/authStore";
38 import { Can } from "@/app/components/Can";
39 import { PERMISSIONS } from "@/app/types/permissions";
40
41 interface UserListResponse {
42 status: string;
43 data: {
44 users: UserData[];
45 currentPage: number;
46 totalPages: number;
47 totalUsers: number;
48 sortField: string;
49 sortOrder: string;
50 };
51 }
52
53 interface SortConfig {
54 field: string;
55 order: "asc" | "desc";
56 }
57
58 interface UsersTabProps {
59 userType?: "crm" | "customer";
60 title?: string;
61 description?: string;
62 searchPlaceholder?: string;
63 emptyMessage?: string;
64 showOrganizationFilter?: boolean;
65 showCreateButton?: boolean;
66 apiBasePath?: string;
67 routeBasePath?: string;
68 }
69
70 const getRoleColorClass = (role: string): string => {
71 switch (role) {
72 case "super_admin":
73 return "bg-red-100 text-red-800";
74 case "owner":
75 return "bg-purple-100 text-purple-800";
76 case "admin":
77 return "bg-blue-100 text-blue-800";
78 case "contributor":
79 return "bg-green-100 text-green-800";
80 case "viewer":
81 return "bg-gray-100 text-gray-800";
82 default:
83 return "bg-gray-100 text-gray-800";
84 }
85 };
86
87 const UsersTab: React.FC<UsersTabProps> = ({
88 userType = "crm",
89 title = "CRM Users",
90 description = "A list of all CRM users in the system",
91 searchPlaceholder = "Search CRM users by name or email...",
92 emptyMessage = "No CRM users found",
93 showOrganizationFilter = true,
94 showCreateButton = true,
95 apiBasePath = "/users",
96 routeBasePath = "/users",
97 }) => {
98 const t = useTranslation();
99 const router = useRouter();
100 const { organization } = useOrganization();
101 const drillDownOrgId = useAuthStore((s) => s.drillDownOrgId);
102 const [users, setUsers] = useState<UserData[]>([]);
103 const [loading, setLoading] = useState(true);
104 const [error, setError] = useState<string | React.ReactNode>("");
105 const [currentPage, setCurrentPage] = useState(1);
106 const [totalPages, setTotalPages] = useState(1);
107 const [sortConfig, setSortConfig] = useState<SortConfig>({
108 field: "lastName",
109 order: "desc",
110 });
111 const [searchTerm, setSearchTerm] = useState("");
112 const debouncedSearchTerm = useDebounce(searchTerm, 300);
113 const [organizations, setOrganizations] = useState<Organization[]>([]);
114 const [organizationFilter, setOrganizationFilter] = useState("all");
115 const [isCreatingUser, setIsCreatingUser] = useState(false);
116 const [createdUserData, setCreatedUserData] = useState<UserCreatedData | null>(null);
117 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
118 const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
119 const [isDeleting, setIsDeleting] = useState(false);
120
121 const getOrganizationName = (organizationId: string | undefined): string => {
122 if (!organizationId) return "";
123 const organization = organizations.find((org) => org.id === organizationId);
124 return organization?.displayName || "";
125 };
126
127 const fetchUsers = useCallback(async () => {
128 try {
129 setLoading(true);
130 setError("");
131
132 const effectiveOrgFilter = showOrganizationFilter
133 ? organizationFilter
134 : drillDownOrgId ?? "all";
135
136 const response = await api.get<UserListResponse>(
137 `${apiBasePath}?page=${currentPage}&limit=10&sortField=${sortConfig.field}&sortOrder=${sortConfig.order}&userType=${userType}${debouncedSearchTerm ? `&search=${encodeURIComponent(debouncedSearchTerm)}` : ""}${effectiveOrgFilter !== "all" ? `&organizationId=${encodeURIComponent(effectiveOrgFilter)}` : ""}`
138 );
139
140 // Filter out deleted users
141 const activeUsers = response.data.data.users.filter((user) => !user.deleted);
142 setUsers(activeUsers);
143 setTotalPages(response.data.data.totalPages);
144 } catch (error) {
145 setError(t("Failed to fetch users"));
146 console.error("Error fetching users:", error);
147 } finally {
148 setLoading(false);
149 }
150 }, [
151 currentPage,
152 sortConfig.field,
153 sortConfig.order,
154 debouncedSearchTerm,
155 organizationFilter,
156 userType,
157 showOrganizationFilter,
158 organization?.id,
159 apiBasePath,
160 drillDownOrgId,
161 ]);
162
163 useEffect(() => {
164 fetchUsers();
165 }, [fetchUsers]);
166
167 useEffect(() => {
168 const loadOrganizations = async () => {
169 try {
170 const res = await api.get<{ status: string; data?: Organization[] }>("/organizations");
171 setOrganizations(res.data.data || []);
172 } catch {}
173 };
174 void loadOrganizations();
175 }, []);
176
177 const handleSort = (field: string) => {
178 setSortConfig((prevConfig) => ({
179 field,
180 order: prevConfig.field === field && prevConfig.order === "asc" ? "desc" : "asc",
181 }));
182 setCurrentPage(1);
183 };
184
185 const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
186 setSearchTerm(event.target.value);
187 setCurrentPage(1);
188 };
189
190 const clearSearch = () => {
191 setSearchTerm("");
192 setCurrentPage(1);
193 };
194
195 const handleCreateUser = () => {
196 setIsCreatingUser(true);
197 setCreatedUserData(null);
198 };
199
200 const handleCancelCreate = () => {
201 setIsCreatingUser(false);
202 setCreatedUserData(null);
203 };
204
205 const handleUserCreated = (data: UserCreatedData) => {
206 setIsCreatingUser(false);
207 setCreatedUserData(data);
208 };
209
210 const handleBackToUsers = () => {
211 setCreatedUserData(null);
212 fetchUsers(); // Refresh the users list
213 };
214
215 const handleDeleteClick = (e: React.MouseEvent, user: UserData) => {
216 e.stopPropagation(); // Prevent row click navigation
217 setUserToDelete(user);
218 setDeleteConfirmOpen(true);
219 };
220
221 const handleConfirmDelete = async () => {
222 if (!userToDelete) return;
223
224 setIsDeleting(true);
225 try {
226 await api.delete(`${apiBasePath}/${userToDelete.id}`);
227 showSuccessToast(t("User deleted successfully"));
228 setDeleteConfirmOpen(false);
229 setUserToDelete(null);
230 fetchUsers(); // Refresh the list
231 } catch (error) {
232 console.error("Error deleting user:", error);
233 showErrorToast(t("Failed to delete user"));
234 } finally {
235 se{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/admin/page.tsx"}1 "use client";
2
3 import React from "react";
4
5 import { useSearchParams } from "next/navigation";
6
7 import { useTranslation } from "@gangkharai/lmt/client";
8
9 import AuditLogsTab from "@/app/components/admin/AuditLogsTab";
10 import DataModelTab from "@/app/components/admin/DataModelTab";
11 import InsuranceTypesTab from "@/app/components/admin/InsuranceTypesTab";
12 import IntegrationsTab from "@/app/components/admin/IntegrationsTab";
13 import ModulesTab from "@/app/components/admin/ModulesTab";
14 import OrganizationsTab from "@/app/components/admin/OrganizationsTab";
15 import PartnersTab from "@/app/components/admin/PartnersTab";
16 import UsersTab from "@/app/components/admin/UsersTab";
17 import { AdminOnlyRoute } from "@/app/components/AdminOnlyRoute";
18 import AppLayout from "@/app/components/AppLayout";
19
20 type AdminTab =
21 | "users"
22 | "organizations"
23 | "modules"
24 | "partners"
25 | "integrations"
26 | "insurance-types"
27 | "data-model"
28 | "audit-logs";
29
30 const AdminPage: React.FC = () => {
31 const searchParams = useSearchParams();
32 const activeTab = (searchParams.get("tab") as AdminTab | null) || "organizations";
33
34 return (
35 <AdminOnlyRoute>
36 <AppLayout>
37 {activeTab === "organizations" && <OrganizationsTab />}
38 {activeTab === "users" && <UsersTab />}
39 {activeTab === "modules" && <ModulesTab />}
40 {activeTab === "partners" && <PartnersTab />}
41 {activeTab === "integrations" && <IntegrationsTab />}
42 {activeTab === "insurance-types" && <InsuranceTypesTab />}
43 {activeTab === "data-model" && <DataModelTab />}
44 {activeTab === "audit-logs" && <AuditLogsTab />}
45 </AppLayout>
46 </AdminOnlyRoute>
47 );
48 };
49
50 export default AdminPage;
51 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/types/admin.ts"}1 export type DisableClaimAiAssessmentMode = "none" | "customer" | "all";
2
3 export type OrganizationType = "insurer" | "broker" | "affinity";
4
5 export interface Organization {
6 id: string;
7 name: string;
8 displayName?: string;
9 description?: string;
10 portalDomain: string;
11 portalUrl: string;
12 apiUrl?: string;
13 portalType: "customer" | "crm";
14 portalMode: "standalone" | "embedded";
15 active: boolean;
16 timezone?: string;
17 settings?: Record<string, unknown>;
18 disableClaimAiAssessment?: DisableClaimAiAssessmentMode;
19 disableClaimMessages?: boolean;
20 organizationType: OrganizationType;
21 parentOrganizationId: string | null;
22 disablePolicyMessages?: boolean;
23 createdAt: string;
24 updatedAt: string;
25 }
26
27 import { RoleLevel } from './permissions';
28
29 export type CreateOrganization = Omit<
30 Organization,
31 "id" | "createdAt" | "updatedAt" | "organizationType" | "parentOrganizationId"
32 > & {
33 organizationType?: OrganizationType;
34 parentOrganizationId?: string | null;
35 };
36 export type UpdateOrganization = Partial<CreateOrganization>;
37
38 export type ModuleCategory = "core" | "business" | "admin" | "analytics" | "integration";
39
40 export interface ModuleDef {
41 id: number;
42 key: string;
43 name: string;
44 description?: string;
45 icon?: string;
46 route: string;
47 category: ModuleCategory;
48 sortOrder?: number;
49 requiredRole?: RoleLevel;
50 active: boolean;
51 }
52
53 export type CreateModuleDef = Omit<ModuleDef, "id">;
54 export type UpdateModuleDef = Partial<CreateModuleDef>;
55
56 export interface Partner {
57 id: string;
58 name: string;
59 createdAt: string;
60 updatedAt: string;
61 }
62
63 export type CreatePartner = Omit<Partner, "id" | "createdAt" | "updatedAt">;
64 export type UpdatePartner = Partial<CreatePartner>;
65
66 export type PartnerEnvironmentType = "test" | "staging" | "production";
67
68 export interface PartnerEnvironment {
69 id: string;
70 partnerId: string;
71 environment: PartnerEnvironmentType;
72 baseUrl: string;
73 authEndpoint?: string;
74 tokenEndpoint?: string;
75 }
76
77 export type CreatePartnerEnvironment = Omit<PartnerEnvironment, "id">;
78 export type UpdatePartnerEnvironment = Partial<CreatePartnerEnvironment>;
79
80 export type AuthType =
81 | "client_credentials"
82 | "username_password"
83 | "api_key"
84 | "basic_auth"
85 | "oauth2_code"
86 | "custom";
87
88 export type TokenType = "bearer" | "basic" | "custom";
89
90 export interface PartnerAuthConfig {
91 id: string;
92 partnerEnvironmentId: string;
93 authType: AuthType;
94 tokenType: TokenType;
95 authorizationHeaderFormat?: string;
96 encryptedCredentials?: string;
97 active: boolean;
98 }
99
100 export interface CreatePartnerAuthConfig {
101 partnerEnvironmentId: string;
102 authType:
103 | "client_credentials"
104 | "username_password"
105 | "api_key"
106 | "basic_auth"
107 | "oauth2_code"
108 | "custom";
109 tokenType: "bearer" | "basic" | "custom";
110 authorizationHeaderFormat?: string;
111 active: boolean;
112 // Credential fields (based on authType)
113 username?: string;
114 password?: string;
115 clientId?: string;
116 clientSecret?: string;
117 apiKey?: string;
118 }
119
120 export interface UpdatePartnerAuthConfig {
121 authType?:
122 | "client_credentials"
123 | "username_password"
124 | "api_key"
125 | "basic_auth"
126 | "oauth2_code"
127 | "custom";
128 tokenType?: "bearer" | "basic" | "custom";
129 authorizationHeaderFormat?: string;
130 active?: boolean;
131 // Credential fields (only sent if updating)
132 username?: string;
133 password?: string;
134 clientId?: string;
135 clientSecret?: string;
136 apiKey?: string;
137 }
138
139 export interface OrganizationModuleAssignment {
140 id: number;
141 organizationId: string;
142 moduleId: number;
143 active: boolean;
144 settings?: Record<string, unknown>;
145 usePartnerApi?: boolean;
146 lastAccessed?: string;
147 }
148
149 export type CreateOrganizationModuleAssignment = Omit<OrganizationModuleAssignment, "id">;
150 export type UpdateOrganizationModuleAssignment = Partial<CreateOrganizationModuleAssignment>;
151
152 export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
153
154 export interface ModuleEndpoint {
155 id: number;
156 moduleId: number;
157 method: HttpMethod;
158 urlPattern: string;
159 controller?: string;
160 apiVersion?: string;
161 functionName?: string;
162 description?: string;
163 }
164
165 export type CreateModuleEndpoint = Omit<ModuleEndpoint, "id">;
166 export type UpdateModuleEndpoint = Partial<CreateModuleEndpoint>;
167
168 export interface PartnerEndpointMapping {
169 id: number;
170 partnerId: string;
171 moduleEndpointId: number;
172 urlPattern?: string;
173 active?: boolean;
174 executionOrder?: number;
175 excludeFromAutoLoading?: boolean;
176 httpMethod?: HttpMethod;
177 urlTemplate?: string;
178 requestBodyTemplate?: string | null;
179 responseMapping?: string | null;
180 dependsOnEndpoint?: number | null;
181 parameterTransformations?: string | null;
182 }
183
184 export type CreatePartnerEndpointMapping = Omit<PartnerEndpointMapping, "id">;
185 export type UpdatePartnerEndpointMapping = Partial<CreatePartnerEndpointMapping>;
186
187 export interface PartnerFieldMapping {
188 id: number;
189 partnerEndpointMappingId: number;
190 partnerFieldPath: string;
191 dataModelFieldId?: number;
192 customFieldName?: string;
193 transformationFunction?: string;
194 isRequired?: boolean;
195 defaultValue?: string;
196 enumMapping?: Record<string, string> | null;
197 direction?: "request" | "response";
198 }
199
200 export type CreatePartnerFieldMapping = Omit<PartnerFieldMapping, "id">;
201 export type UpdatePartnerFieldMapping = Partial<CreatePartnerFieldMapping>;
202
203 export interface ProductModulePartner {
204 id: number;
205 productId: number;
206 moduleId: number;
207 partnerId: string;
208 module?: ModuleDef;
209 partner?: Partner;
210 createdAt?: string;
211 updatedAt?: string;
212 }
213
214 export type CreateProductModulePartner = Omit<
215 ProductModulePartner,
216 "id" | "module" | "partner" | "createdAt" | "updatedAt"
217 >;
218 export type UpdateProductModulePartner = Partial<Pick<ProductModulePartner, "partnerId">>;
219
220 export interface PagedResponse<T> {
221 status: "success" | "error";
222 data?: {
223 items: T[];
224 total: number;
225 page: number;
226 pageSize: number;
227 };
228 message?: string;
229 }
230
231 export interface ListResponse<T> {
232 status: "success" | "error";
233 data?: T[];
234 message?: string;
235 }
236
237 // ─── External integrations (outbound third-party API framework) ──────────────
238
239 export type IntegrationCapability =
240 | "sanction_screening"
241 | "pep"
242 | "kyc"
243 | "adverse_media"
244 | "fraud_detection";
245
246 // Operations a capability supports. Today every compliance capability has a
247 // single 'check' operation; widen this when a capability gains more.
248 export const CAPABILITY_OPERATIONS: Record<IntegrationCapability, readonly string[]> = {
249 sanction_screening: ["check"],
250 pep: ["check"],
251 kyc: ["check"],
252 adverse_media: ["check"],
253 fraud_detection: ["check"],
254 };
255 export type IntegrationKind = "generic_rest";
256 export type IntegrationEnvironment = "test" | "staging" | "production";
257
258 export type IntegrationInvocationMode = "sync" | "async";
259
260 // Sensible per-capability defaults. Slow review-style providers default to
261 // async so the customer isn't blocked behind document review.
262 export const CAPABILITY_DEFAULT_INVOCATION_MODE: Record<IntegrationCapability, IntegrationInvocationMode> = {
263 sanction_Now I understand current table. 7 columns: Name, Email, Role, Organization (single, via user.organizationId), Created At, 2FA Status, Actions. Org filter exists, no active filter, no active column. Check UserData type for available fields (orgs array? active flag?).
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/types/user.ts"}1 // types.ts
2
3 export interface UserOrganizationMembership {
4 id: string;
5 name: string;
6 displayName?: string;
7 UserOrganization: {
8 role: string;
9 isActive: boolean;
10 createdAt: string;
11 };
12 }
13
14 export interface UserData {
15 id: string;
16 firstName: string;
17 lastName: string;
18 companyName?: string;
19 email: string;
20 phoneNumber: string;
21 country: string;
22 region: string;
23 address: string;
24 city: string;
25 postalCode: string;
26 role: string;
27 userType: string;
28 twoFactorMethod?: string;
29 twoFactorEnabled: boolean;
30 createdAt: string;
31 updatedAt: string;
32 deleted: boolean;
33 organizationId?: string;
34 organizationName?: string;
35 organization?: {
36 id: string;
37 name: string;
38 displayName?: string;
39 };
40 organizations?: UserOrganizationMembership[];
41 }
42
43 export interface AuthContextType {
44 user: UserData | null;
45 loading: boolean;
46 login: (accessToken: string, refreshToken: string, userData?: Partial<UserData>) => Promise<void>;
47 logout: () => void;
48 refreshToken: () => Promise<string | null>;
49 }
50
51 export type EditableSection = "personalInfo" | "contactInfo" | "address" | "password";
52
53 export interface PasswordRequirement {
54 regex: RegExp;
55 text: string;
56 }
57
58 export const passwordRequirements: PasswordRequirement[] = [
59 { regex: /.{8,}/, text: "At least 8 characters long" },
60 { regex: /[A-Z]/, text: "At least one uppercase letter" },
61 { regex: /[a-z]/, text: "At least one lowercase letter" },
62 { regex: /[0-9]/, text: "At least one number" },
63 { regex: /[@$!%*?&]/, text: "At least one special character (@$!%*?&)" },
64 ];
65
66 export interface PersonalInfoSectionProps {
67 userData: UserData;
68 onUpdate: (data: Partial<UserData>) => Promise<void>;
69 isLoading: boolean;
70 }
71
72 export interface ContactInfoSectionProps {
73 userData: UserData;
74 onUpdate: (data: Partial<UserData>) => Promise<void>;
75 isLoading: boolean;
76 }
77
78 export interface AddressSectionProps {
79 userData: UserData;
80 onUpdate: (data: Partial<UserData>) => Promise<void>;
81 isLoading: boolean;
82 }
83
84 export interface PasswordChangeSectionProps {
85 userId: string;
86 onSuccess: () => void;
87 onError: (message: string) => void;
88 }
89
90 export interface FormData {
91 firstName: string;
92 lastName: string;
93 companyName?: string;
94 email: string;
95 password: string;
96 phoneNumber: string;
97 country: string;
98 region: string;
99 address: string;
100 city: string;
101 postalCode: string;
102 }
103
104 export type LogoutSectionProps = Record<string, never>;
105 Now fully grounded. Key facts:
UserData.organizations?: UserOrganizationMembership[] exists — each membership has { id, name, displayName, UserOrganization: { role, isActive, createdAt } }. This multi-org data is NOT rendered (the bug). Table only shows single getOrganizationName(user.organizationId).active field; active status is per-membership (UserOrganization.isActive).Organization.active exists (Organizations tab likely already shows active badge — pattern to match).Browser time. Check which playwright package importable, then write driver.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-test && echo \"---PW PKGS---\" && ls node_modules | rg -i \"^playwright\" ; echo \"---CORE EXPORTS chromium?---\" && node -e \"const p=require('playwright-core'); console.log('chromium' in p)\" 2>&1; echo \"---@pw/test chromium?---\" && node -e \"const p=require('@playwright/test'); console.log('chromium' in p)\" 2>&1","description":"Confirm playwright package and chromium export"}---PW PKGS--- playwright playwright-core ---CORE EXPORTS chromium?--- true ---@pw/test chromium?--- true
Both importable. Writing resilient driver script. Captures main surface + control/header closeups + org-filter dropdown (pattern to match) + organizations tab (active-badge pattern) + a user detail page.
{"file_path":"/tmp/agentb-driver.mjs","content":"import { chromium } from 'playwright';\nimport fs from 'fs';\n\nconst AUTH = '/Users/bakker/dev/gangkhar/sherpa-test/playwright/.auth/user.json';\nconst PROOF = '/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof';\nconst PORTAL = 'http://localhost:3000';\n\nconst log = (...a) => console.log('[driver]', ...a);\nconst summary = { steps: [], shots: [] };\nconst shot = async (page, name, opts = {}) => {\n const path = `${PROOF}/${name}`;\n await page.screenshot({ path, ...opts });\n summary.shots.push(name);\n log('shot', name);\n};\n\nconst browser = await chromium.launch({ headless: true });\nconst ctx = await browser.newContext({\n storageState: AUTH,\n viewport: { width: 1536, height: 960 },\n deviceScaleFactor: 1,\n});\nconst page = await ctx.newPage();\npage.setDefaultTimeout(20000);\n\nasync function step(label, fn) {\n try { const r = await fn(); summary.steps.push({ label, ok: true, info: r ?? null }); return r; }\n catch (e) { summary.steps.push({ label, ok: false, error: String(e).slice(0, 300) }); log('FAIL', label, String(e).slice(0,200)); return null; }\n}\n\n// ---- 1. Admin > Users tab (the feature surface) ----\nawait step('goto users tab', async () => {\n await page.goto(`${PORTAL}/admin?tab=users`, { waitUntil: 'networkidle', timeout: 45000 });\n await page.waitForTimeout(1500);\n return { url: page.url() };\n});\n\n// auth / state probe\nconst probe = await step('probe page state', async () => {\n const url = page.url();\n const loginBtn = await page.getByRole('button', { name: /log in with microsoft/i }).isVisible().catch(() => false);\n const heading = await page.getByRole('heading', { name: /CRM Users/i }).isVisible().catch(() => false);\n const rows = await page.locator('table tbody tr').count().catch(() => 0);\n const headers = await page.locator('table thead th').allInnerTexts().catch(() => []);\n const bodyText = (await page.locator('body').innerText().catch(() => '')).slice(0, 400);\n return { url, loginBtn, heading, rows, headers, bodyText };\n});\nlog('probe', JSON.stringify(probe, null, 2));\n\nawait shot(page, 'admin-users-tab-current-full.png', { fullPage: true });\nawait shot(page, 'admin-users-tab-viewport.png');\n\n// ---- 2. Controls closeup (search + Organization filter = where new active filter attaches) ----\nawait step('controls closeup', async () => {\n const search = page.locator('input[placeholder*=\"Search\" i]').first();\n await search.waitFor({ timeout: 8000 });\n const box = await search.boundingBox();\n if (box) {\n await page.screenshot({\n path: `${PROOF}/admin-users-controls-closeup.png`,\n clip: { x: Math.max(0, box.x - 20), y: Math.max(0, box.y - 30), width: 900, height: 230 },\n });\n summary.shots.push('admin-users-controls-closeup.png');\n }\n});\n\n// ---- 3. Table header closeup (current columns) ----\nawait step('header closeup', async () => {\n const thead = page.locator('table thead').first();\n await thead.waitFor({ timeout: 8000 });\n const box = await thead.boundingBox();\n if (box) {\n await page.screenshot({\n path: `${PROOF}/admin-users-table-header.png`,\n clip: { x: box.x, y: box.y, width: Math.min(1500, box.width), height: box.height + 8 },\n });\n summary.shots.push('admin-users-table-header.png');\n }\n});\n\n// ---- 4. Organization filter dropdown OPEN (radix Select pattern to match) ----\nawait step('open org filter dropdown', async () => {\n const trigger = page.getByRole('combobox').first();\n await trigger.click({ timeout: 8000 });\n await page.waitForTimeout(600);\n await shot(page, 'admin-users-org-filter-open.png');\n await page.keyboard.press('Escape').catch(() => {});\n});\n\n// ---- 5. User detail page (where multi-org membership surfaces today) ----\nawait step('open first user detail', async () => {\n const firstRow = page.locator('table tbody tr').first();\n const rowText = await firstRow.innerText().catch(() => '');\n await firstRow.click({ timeout: 8000 });\n await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});\n await page.waitForTimeout(1200);\n await shot(page, 'user-detail-page-full.png', { fullPage: true });\n return { rowText: rowText.slice(0, 120), detailUrl: page.url() };\n});\n\n// ---- 6. Organizations tab (existing Active status badge pattern to match) ----\nawait step('goto organizations tab', async () => {\n await page.goto(`${PORTAL}/admin?tab=organizations`, { waitUntil: 'networkidle', timeout: 45000 });\n await page.waitForTimeout(1500);\n const headers = await page.locator('table thead th').allInnerTexts().catch(() => []);\n await shot(page, 'admin-organizations-tab-full.png', { fullPage: true });\n return { url: page.url(), headers };\n});\n\n// ---- 7. Sidebar / nav closeup (navigation path) ----\nawait step('nav sidebar closeup', async () => {\n const nav = page.locator('nav').first();\n if (await nav.isVisible().catch(() => false)) {\n const box = await nav.boundingBox();\n if (box && box.width > 60) {\n await page.screenshot({\n path: `${PROOF}/portal-nav-sidebar.png`,\n clip: { x: box.x, y: box.y, width: Math.min(420, box.width + 10), height: Math.min(940, box.height) },\n });\n summary.shots.push('portal-nav-sidebar.png');\n }\n }\n});\n\nfs.writeFileSync(`${PROOF}/_driver-summary.json`, JSON.stringify(summary, null, 2));\nlog('DONE');\nconsole.log('SUMMARY_JSON_START');\nconsole.log(JSON.stringify(summary));\nconsole.log('SUMMARY_JSON_END');\nawait browser.close();\n"}File created successfully at: /tmp/agentb-driver.mjs (file state is current in your context — no need to Read it back)
Run driver from sherpa-test dir (resolves playwright modules).
{"command":"node /tmp/agentb-driver.mjs 2>&1","description":"Run Playwright driver to capture admin users screens","timeout":180000}Exit code 1
node:internal/modules/package_json_reader:301
throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'playwright' imported from /private/tmp/agentb-driver.mjs
at Object.getPackageJSONURL (node:internal/modules/package_json_reader:301:9)
at packageResolve (node:internal/modules/esm/resolve:764:81)
at moduleResolve (node:internal/modules/esm/resolve:855:18)
at defaultResolve (node:internal/modules/esm/resolve:988:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:697:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:714:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:746:52)
at #resolve (node:internal/modules/esm/loader:679:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:599:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:162:33) {
code: 'ERR_MODULE_NOT_FOUND'
}
Node.js v25.9.0ESM resolves from file location, not cwd. Use absolute import path. Check entry.
{"command":"node -e \"console.log(require.resolve('playwright'))\" 2>&1","description":"Resolve absolute path to playwright entry"}/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js
{"replace_all":false,"file_path":"/tmp/agentb-driver.mjs","old_string":"import { chromium } from 'playwright';","new_string":"import { chromium } from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';"}The file /tmp/agentb-driver.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
{"command":"node /tmp/agentb-driver.mjs 2>&1","description":"Run Playwright driver with absolute import","timeout":180000}Exit code 1
file:///private/tmp/agentb-driver.mjs:1
import { chromium } from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';
^^^^^^^^
SyntaxError: Named export 'chromium' not found. The requested module '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';
const { chromium } = pkg;
at #asyncInstantiate (node:internal/modules/esm/module_job:326:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:429:5)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:639:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v25.9.0CJS. Default import.
{"replace_all":false,"file_path":"/tmp/agentb-driver.mjs","old_string":"import { chromium } from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';","new_string":"import pw from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';\nconst { chromium } = pw;"}The file /tmp/agentb-driver.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
{"command":"node /tmp/agentb-driver.mjs 2>&1","description":"Run Playwright driver (default import)","timeout":180000}[driver] probe {
"url": "http://localhost:3000/login",
"loginBtn": true,
"heading": false,
"rows": 0,
"headers": [],
"bodyText": "English\nEspañol\nPortuguês\nFrançais\nBahasa Indonesia\nNorsk\nSvenska\nSuomi\nDansk\nNederlands\nItaliano\nDeutsch\nLog in to Sherpa Portal\nLog in with Microsoft Entra ID"
}
[driver] shot admin-users-tab-current-full.png
[driver] shot admin-users-tab-viewport.png
[driver] FAIL controls closeup TimeoutError: locator.waitFor: Timeout 8000ms exceeded.
Call log:
- waiting for locator('input[placeholder*="Search" i]').first() to be visible
[driver] FAIL header closeup TimeoutError: locator.waitFor: Timeout 8000ms exceeded.
Call log:
- waiting for locator('table thead').first() to be visible
[driver] shot admin-users-org-filter-open.png
[driver] FAIL open first user detail TimeoutError: locator.click: Timeout 8000ms exceeded.
Call log:
- waiting for locator('table tbody tr').first()
[driver] shot admin-organizations-tab-full.png
[driver] DONE
SUMMARY_JSON_START
{"steps":[{"label":"goto users tab","ok":true,"info":{"url":"http://localhost:3000/login"}},{"label":"probe page state","ok":true,"info":{"url":"http://localhost:3000/login","loginBtn":true,"heading":false,"rows":0,"headers":[],"bodyText":"English\nEspañol\nPortuguês\nFrançais\nBahasa Indonesia\nNorsk\nSvenska\nSuomi\nDansk\nNederlands\nItaliano\nDeutsch\nLog in to Sherpa Portal\nLog in with Microsoft Entra ID"}},{"label":"controls closeup","ok":false,"error":"TimeoutError: locator.waitFor: Timeout 8000ms exceeded.\nCall log:\n - waiting for locator('input[placeholder*=\"Search\" i]').first() to be visible\n"},{"label":"header closeup","ok":false,"error":"TimeoutError: locator.waitFor: Timeout 8000ms exceeded.\nCall log:\n - waiting for locator('table thead').first() to be visible\n"},{"label":"open org filter dropdown","ok":true,"info":null},{"label":"open first user detail","ok":false,"error":"TimeoutError: locator.click: Timeout 8000ms exceeded.\nCall log:\n - waiting for locator('table tbody tr').first()\n"},{"label":"goto organizations tab","ok":true,"info":{"url":"http://localhost:3000/login","headers":[]}},{"label":"nav sidebar closeup","ok":true,"info":null}],"shots":["admin-users-tab-current-full.png","admin-users-tab-viewport.png","admin-users-org-filter-open.png","admin-organizations-tab-full.png"]}
SUMMARY_JSON_ENDAuth state stale (saved Apr 28) — redirected to /login. Need fresh storage state. The setup project does Microsoft OAuth using .env creds. Check password var exists, then run setup to refresh auth.
{"command":"node -e \"require('dotenv').config(); console.log('EMAIL set:', !!process.env.TEST_USER_EMAIL, '| PASSWORD set:', !!process.env.TEST_USER_PASSWORD)\" 2>&1","description":"Check TEST_USER creds present in env"}EMAIL set: true | PASSWORD set: true
Both creds set. Refresh auth via setup project (does Microsoft OAuth, regenerates user.json).
{"command":"node_modules/.bin/playwright test --project=setup --reporter=list 2>&1 | tail -40","description":"Run setup project to refresh auth storage state","timeout":180000}Running 1 test using 1 worker Account picker detected, clicking existing account Login successful, saving storage state ✓ 1 [setup] › tests/auth.setup.ts:7:6 › authenticate (4.3s) 1 passed (6.6s)
Auth refreshed (account-picker path, 4.3s). Re-run driver.
{"command":"node /tmp/agentb-driver.mjs 2>&1","description":"Re-run driver with fresh auth","timeout":180000}[driver] probe {
"url": "http://localhost:3000/admin?tab=users",
"loginBtn": false,
"heading": true,
"rows": 8,
"headers": [
"Name",
"Email",
"Role",
"Organization",
"Created At",
"2FA Status",
"Actions"
],
"bodyText": "Back to Products\nOrganizations\nUsers\nModules\nAPI Integrations\nExternal Integrations\nInsurance Types\nData Model\nAudit Logs\n\nE2E Test User\n\[email protected]\n\nCRM Users\n\nA list of all CRM users in the system\n\nCreate User\nOrganization\nAll\nName\n\t\nEmail\n\tRole\tOrganization\t\nCreated At\n\t2FA Status\t\nActions\n\nRoeland Werring\[email protected]\tSuper Admin\t\t4/17/2026\tDisabled\t\n\nAPI Test User\ta"
}
[driver] shot admin-users-tab-current-full.png
[driver] shot admin-users-tab-viewport.png
[driver] shot admin-users-org-filter-open.png
[driver] shot user-detail-page-full.png
[driver] shot admin-organizations-tab-full.png
[driver] DONE
SUMMARY_JSON_START
{"steps":[{"label":"goto users tab","ok":true,"info":{"url":"http://localhost:3000/admin?tab=users"}},{"label":"probe page state","ok":true,"info":{"url":"http://localhost:3000/admin?tab=users","loginBtn":false,"heading":true,"rows":8,"headers":["Name","Email","Role","Organization","Created At","2FA Status","Actions"],"bodyText":"Back to Products\nOrganizations\nUsers\nModules\nAPI Integrations\nExternal Integrations\nInsurance Types\nData Model\nAudit Logs\n\nE2E Test User\n\[email protected]\n\nCRM Users\n\nA list of all CRM users in the system\n\nCreate User\nOrganization\nAll\nName\n\t\nEmail\n\tRole\tOrganization\t\nCreated At\n\t2FA Status\t\nActions\n\nRoeland Werring\[email protected]\tSuper Admin\t\t4/17/2026\tDisabled\t\n\nAPI Test User\ta"}},{"label":"controls closeup","ok":true,"info":null},{"label":"header closeup","ok":true,"info":null},{"label":"open org filter dropdown","ok":true,"info":null},{"label":"open first user detail","ok":true,"info":{"rowText":"Roeland Werring\[email protected]\tSuper Admin\t\t4/17/2026\tDisabled\t","detailUrl":"http://localhost:3000/admin?tab=users"}},{"label":"goto organizations tab","ok":true,"info":{"url":"http://localhost:3000/admin?tab=organizations","headers":["Name","Display Name","Portal Domain","Type","Mode","Active","Actions"]}},{"label":"nav sidebar closeup","ok":true,"info":null}],"shots":["admin-users-tab-current-full.png","admin-users-tab-viewport.png","admin-users-controls-closeup.png","admin-users-table-header.png","admin-users-org-filter-open.png","user-detail-page-full.png","admin-organizations-tab-full.png","portal-nav-sidebar.png"]}
SUMMARY_JSON_ENDAll 8 shots captured. Live confirmation:
Verify visually + check the user-detail nav (URL probe still showed users tab). View key screenshots.
{"command":"ls -la /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof","description":"List captured screenshots with sizes"}total 1448 drwxr-xr-x@ 11 bakker wheel 352 Jun 2 13:24 . drwxr-xr-x@ 12 bakker wheel 384 Jun 2 13:20 .. -rw-r--r--@ 1 bakker wheel 2288 Jun 2 13:24 _driver-summary.json -rw-r--r--@ 1 bakker wheel 158491 Jun 2 13:24 admin-organizations-tab-full.png -rw-r--r--@ 1 bakker wheel 13622 Jun 2 13:24 admin-users-controls-closeup.png -rw-r--r--@ 1 bakker wheel 133766 Jun 2 13:24 admin-users-org-filter-open.png -rw-r--r--@ 1 bakker wheel 130962 Jun 2 13:24 admin-users-tab-current-full.png -rw-r--r--@ 1 bakker wheel 130962 Jun 2 13:24 admin-users-tab-viewport.png -rw-r--r--@ 1 bakker wheel 6362 Jun 2 13:24 admin-users-table-header.png -rw-r--r--@ 1 bakker wheel 19136 Jun 2 13:24 portal-nav-sidebar.png -rw-r--r--@ 1 bakker wheel 131142 Jun 2 13:24 user-detail-page-full.png
View the key screenshots to ground report (and confirm whether user-detail navigated).
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-tab-current-full.png"}Gold. Bug visually confirmed: Organization column empty for all 8 users. No user Active/Status column, no active filter (only Organization dropdown). View org-filter-open (Select pattern), organizations tab (Active-badge pattern), user-detail.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-org-filter-open.png"}Org filter = radix Select dropdown (All, velora, voltkrone, nordbank, carrefour, o2, oyika, ueno, Sherpa Test Org 7/6/5... scrollable — many orgs). Exact pattern for new active filter. View organizations tab (Active badge) + user-detail.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-organizations-tab-full.png"}Organizations tab confirms the green "Active" badge pattern (Active column) — exact styling to reuse for user active status. View user-detail to check row-click navigation + where memberships surface.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/user-detail-page-full.png"}Row-click fired but dev server showed "Compiling..." toast — navigation didn't finish, screenshot caught the table mid-compile. So user-detail-page-full.png is a stale duplicate. Detail page is secondary (feature is the table), but worth one robust retry with a long wait for dev compile.
{"file_path":"/tmp/agentb-detail.mjs","content":"import pw from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';\nconst { chromium } = pw;\nimport fs from 'fs';\n\nconst AUTH = '/Users/bakker/dev/gangkhar/sherpa-test/playwright/.auth/user.json';\nconst PROOF = '/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof';\nconst PORTAL = 'http://localhost:3000';\n\nconst browser = await chromium.launch({ headless: true });\nconst ctx = await browser.newContext({ storageState: AUTH, viewport: { width: 1536, height: 960 } });\nconst page = await ctx.newPage();\npage.setDefaultTimeout(45000);\n\nawait page.goto(`${PORTAL}/admin?tab=users`, { waitUntil: 'networkidle', timeout: 45000 });\nawait page.locator('table tbody tr').first().waitFor({ timeout: 20000 });\nawait page.locator('table tbody tr').first().click();\n\nlet ok = false;\ntry {\n await page.waitForURL(/\\/users\\/[^/?]+/, { timeout: 45000 });\n await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});\n await page.waitForTimeout(2500); // let dev compile + data settle\n ok = true;\n} catch (e) {\n console.log('[detail] navigation did not reach /users/{id}:', String(e).slice(0,160));\n}\n\nconst url = page.url();\nconst heading = (await page.locator('h1, h2').allInnerTexts().catch(() => [])).slice(0, 6);\nconst bodyText = (await page.locator('main, body').first().innerText().catch(() => '')).slice(0, 800);\nconsole.log('[detail] url:', url);\nconsole.log('[detail] headings:', JSON.stringify(heading));\nconsole.log('[detail] bodyText:', JSON.stringify(bodyText));\n\nif (ok && /\\/users\\//.test(url)) {\n await page.screenshot({ path: `${PROOF}/user-detail-page-full.png`, fullPage: true });\n console.log('[detail] saved user-detail-page-full.png');\n} else {\n // remove the stale duplicate so we don't cite a non-detail shot\n try { fs.unlinkSync(`${PROOF}/user-detail-page-full.png`); console.log('[detail] removed stale shot'); } catch {}\n}\nawait browser.close();\n"}File created successfully at: /tmp/agentb-detail.mjs (file state is current in your context — no need to Read it back)
{"command":"node /tmp/agentb-detail.mjs 2>&1","description":"Retry user-detail navigation with long compile wait","timeout":150000}[detail] url: http://localhost:3000/users/d0a6f897-3ca9-465f-89bd-504f8a9b8278 [detail] headings: ["User Details"] [detail] bodyText: "ST\nSherpa Test Org\nSHERPA PLUS\nProducts\nPolicy Management\nCustomers\nFinancials\nAdmin\nSHERPA LENS\nOptimizations\nData\n\nE2E Test User\n\[email protected]\n\nBack to Users\nUser Details\nPersonal Information\nEdit\nCompany name\n-\nFirst name\nRoeland\nLast name\nWerring\nContact Information\nEdit\nEmail address\[email protected]\nPhone number\nAddress\nEdit\nCountry\nState/Region\nAddress\nCity\nPostal Code\nPassword\nChange Password\n\n••••••••\n\nTwo-Factor Authentication\n2FA Method\n\nemail\n\n2FA Status\nDisabled\nAccount State\nGlobal Role\nSuper Admin\nAccount Status\nActive\nOrganization Memberships (1)\n\nSherpa Portal - Main CRM\n\nRole: Owner\nStatus: Active\nJoined: 4/17/2026\nActive\nPolicies\n\nPolicies associated with this user\n\nNo policies found for this user\n\nUser Activity\n\nView audit logs for this user\n\nView A" [detail] saved user-detail-page-full.png
Jackpot. Detail page /users/{id} already renders exactly the feature's data:
View the detail shot to capture the visual pattern.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/user-detail-page-full.png"}Full-page detail captured; Account State + Organization Memberships sections are lower in the tall PNG. They're the most relevant existing UI for this feature, so capture a focused clip of that region.
{"file_path":"/tmp/agentb-membership.mjs","content":"import pw from '/Users/bakker/dev/gangkhar/sherpa-test/node_modules/playwright/index.js';\nconst { chromium } = pw;\n\nconst AUTH = '/Users/bakker/dev/gangkhar/sherpa-test/playwright/.auth/user.json';\nconst PROOF = '/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof';\nconst URL = 'http://localhost:3000/users/d0a6f897-3ca9-465f-89bd-504f8a9b8278';\n\nconst browser = await chromium.launch({ headless: true });\nconst ctx = await browser.newContext({ storageState: AUTH, viewport: { width: 1536, height: 960 } });\nconst page = await ctx.newPage();\npage.setDefaultTimeout(45000);\n\nawait page.goto(URL, { waitUntil: 'networkidle', timeout: 45000 });\nawait page.getByText(/Organization Memberships/i).first().waitFor({ timeout: 20000 });\nawait page.getByText(/Account State/i).first().scrollIntoViewIfNeeded().catch(() => {});\nawait page.waitForTimeout(800);\n\n// clip from Account State heading down through the memberships card\nconst acct = await page.getByText(/Account State/i).first().boundingBox().catch(() => null);\nconst mem = await page.getByText(/Organization Memberships/i).first().boundingBox().catch(() => null);\nconst top = acct ? acct.y - 20 : (mem ? mem.y - 60 : 200);\nawait page.screenshot({\n path: `${PROOF}/user-detail-account-state-memberships.png`,\n clip: { x: 460, y: Math.max(0, top), width: 880, height: 520 },\n});\nconsole.log('[mem] saved user-detail-account-state-memberships.png; acctBox=', JSON.stringify(acct), 'memBox=', JSON.stringify(mem));\nawait browser.close();\n"}File created successfully at: /tmp/agentb-membership.mjs (file state is current in your context — no need to Read it back)
{"command":"node /tmp/agentb-membership.mjs 2>&1","description":"Capture Account State + Org Memberships section clip","timeout":120000}[mem] saved user-detail-account-state-memberships.png; acctBox= {"x":504,"y":468,"width":784,"height":24} memBox= {"x":532,"y":629,"width":188.296875,"height":20}{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/user-detail-account-state-memberships.png"}Definitive grounding shot. The detail page Account State card already has: Account Status "Active" + blue toggle, Global Role dropdown, and Organization Memberships (N) with a count badge in the heading and a per-membership row ("Sherpa Portal - Main CRM", Role/Status/Joined, green Active pill). The feature = surfacing this detail-page data into the list table + a filter.
Final file list, then write report.
{"command":"ls -1 /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/*.png","description":"List final PNG filenames"}/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-organizations-tab-full.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-controls-closeup.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-org-filter-open.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-tab-current-full.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-tab-viewport.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/admin-users-table-header.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/portal-nav-sidebar.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/user-detail-account-state-memberships.png /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/user-detail-page-full.png
All 9 shots present. Write report.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md","content":"# Live-browser research: Admin > Users (active organizations, active status, active filter)\n\nPortal driven in a real Chromium session (Playwright) using the saved storage state. The stored session had expired (it redirected to `/login`), so I refreshed it with the sherpa-test `setup` project (Microsoft Entra OAuth, account-picker path) and then drove the app authenticated as `[email protected]`.\n\n## Summary\n\nI reached and exercised the exact feature surface, `http://localhost:3000/admin?tab=users` (the \"CRM Users\" screen), plus the supporting screens the feature touches: the user detail page (`/users/{id}`) and the Organizations admin tab (`/admin?tab=organizations`). The embed app was not needed because this feature is portal-admin only (no customer-funnel surface).\n\nThe three requested changes map cleanly onto what exists today:\n\n- The **Organization** column already exists on the Users table but renders **empty for every user** (the reported bug). See `admin-users-tab-current-full.png`.\n- There is **no user active/inactive column** on the table, and **no active/inactive filter**. Only a single \"Organization\" filter exists today.\n- The data the feature needs already lives in the product on the **user detail page**: an \"Account State\" card with an \"Account Status: Active\" toggle and an \"Organization Memberships (N)\" list, each membership carrying a green \"Active\" pill. The feature is essentially surfacing that detail-page data into the list view and adding a status filter.\n\n## Current screens\n\n### 1. CRM Users table (the feature surface)\n\n- Navigation path: left sidebar (Admin context) > **Users**. The admin area uses a left sidebar with a \"Back to Products\" link on top and these entries: Organizations, **Users**, Modules, API Integrations, External Integrations, Insurance Types, Data Model, Audit Logs. Each entry maps to a `?tab=` value on `/admin`; \"Users\" is `/admin?tab=users`.\n- What it shows (see `admin-users-tab-current-full.png`, `admin-users-table-header.png`, `admin-users-controls-closeup.png`, `portal-nav-sidebar.png`):\n - Heading \"CRM Users\", subtitle \"A list of all CRM users in the system\", and a \"Create User\" button top-right.\n - A search input (\"Search CRM users by name or email...\").\n - A single filter labelled \"Organization\", a dropdown defaulting to \"All\".\n - A table with columns, left to right: **Name** (sortable), **Email** (sortable), **Role** (coloured badge, e.g. Super Admin red, Owner purple, Contributor green), **Organization**, **Created At** (sortable), **2FA Status** (green \"Enabled\" / red \"Disabled\" pill), and an unlabelled actions column (a red trash/delete icon).\n - 8 users were visible and the table fit on one screen (no pagination control shown at this row count).\n- The bug, confirmed live: the **Organization column is blank for all 8 rows**. Not one organization name renders. The cell currently resolves a single `user.organizationId` to a name, which is empty or unresolved for these users, so nothing shows.\n\n### 2. Organization filter dropdown (existing filter pattern)\n\n- Navigation path: same screen, click the \"Organization\" dropdown.\n- What it shows (see `admin-users-org-filter-open.png`): a dropdown (a shadcn/radix Select) listing \"All\" (checked) followed by every organization: velora, voltkrone, nordbank, carrefour, o2, oyika, ueno, \"Sherpa Test Organization 7\", \"Sherpa Test Organization 6\", \"Sherpa Test Organization 5\", and more (the list scrolls). This confirms many organizations exist in the system, which is relevant to the 1 to 100+ memberships concern.\n\n### 3. User detail page (where the feature's data already exists)\n\n- Navigation path: click any row in the Users table, which routes to `/users/{id}` (example captured: `/users/d0a6f897-3ca9-465f-89bd-504f8a9b8278`, the \"Roeland Werring\" row). Note: in dev this route compiles on first hit, so the navigation needed a few seconds.\n- What it shows (see `user-detail-page-full.png` and the focused `user-detail-account-state-memberships.png`):\n - Cards for Personal Information, Contact Information, Address, Password, Two-Factor Authentication, and an **\"Account State\"** card.\n - The Account State card shows **Global Role** (a dropdown, e.g. \"Super Admin\"), **Account Status** rendered as the word \"Active\" next to a blue on/off toggle, and **\"Organization Memberships (1)\"** with the count in the heading.\n - Each membership renders as a row: the organization name (e.g. \"Sherpa Portal - Main CRM\") with a shield icon, the line \"Role: Owner Status: Active Joined: 4/17/2026\", and a green **\"Active\"** pill on the right.\n- This is the key finding: both concepts the feature wants in the table (a user-level active status, and a list of the user's organizations with per-membership active status) already exist here. The list view is simply not surfacing them.\n\n### 4. Organizations admin tab (existing \"Active\" badge pattern)\n\n- Navigation path: left sidebar > **Organizations** (`/admin?tab=organizations`).\n- What it shows (see `admin-organizations-tab-full.png`): a table with columns Name, Display Name, Portal Domain, Type (badge), Mode (badge), **Active** (a green \"Active\" pill), and Actions. This confirms the green \"Active\" pill is an established pattern in this admin section and is the natural style to reuse for the new user status column.\n\n## Where the feature attaches\n\nAll three changes attach to the CRM Users table at `/admin?tab=users` (`admin-users-tab-current-full.png`). Concretely:\n\n1. **Active organizations in the table (fixing the empty column).** Reuse the existing 4th column, \"Organization\", on the Users table. The membership data is already available in the app (proven by the \"Organization Memberships (N)\" list on the detail page, `user-detail-account-state-memberships.png`). Because a<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
Output file pre-exists. Read then write.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md"}1 (browser research produced no report) 2
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md","content":"# Live-browser research: Admin > Users (active organizations, active status, active filter)\n\nPortal driven in a real Chromium session (Playwright) using the saved storage state. The stored session had expired (it redirected to `/login`), so I refreshed it with the sherpa-test `setup` project (Microsoft Entra OAuth, account-picker path) and then drove the app authenticated as `[email protected]`.\n\n## Summary\n\nI reached and exercised the exact feature surface, `http://localhost:3000/admin?tab=users` (the \"CRM Users\" screen), plus the supporting screens the feature touches: the user detail page (`/users/{id}`) and the Organizations admin tab (`/admin?tab=organizations`). The embed app was not needed because this feature is portal-admin only (no customer-funnel surface).\n\nThe three requested changes map cleanly onto what exists today:\n\n- The **Organization** column already exists on the Users table but renders **empty for every user** (the reported bug). See `admin-users-tab-current-full.png`.\n- There is **no user active/inactive column** on the table, and **no active/inactive filter**. Only a single \"Organization\" filter exists today.\n- The data the feature needs already lives in the product on the **user detail page**: an \"Account State\" card with an \"Account Status: Active\" toggle and an \"Organization Memberships (N)\" list, each membership carrying a green \"Active\" pill. The feature is essentially surfacing that detail-page data into the list view and adding a status filter.\n\n## Current screens\n\n### 1. CRM Users table (the feature surface)\n\n- Navigation path: left sidebar (Admin context) > **Users**. The admin area uses a left sidebar with a \"Back to Products\" link on top and these entries: Organizations, **Users**, Modules, API Integrations, External Integrations, Insurance Types, Data Model, Audit Logs. Each entry maps to a `?tab=` value on `/admin`; \"Users\" is `/admin?tab=users`.\n- What it shows (see `admin-users-tab-current-full.png`, `admin-users-table-header.png`, `admin-users-controls-closeup.png`, `portal-nav-sidebar.png`):\n - Heading \"CRM Users\", subtitle \"A list of all CRM users in the system\", and a \"Create User\" button top-right.\n - A search input (\"Search CRM users by name or email...\").\n - A single filter labelled \"Organization\", a dropdown defaulting to \"All\".\n - A table with columns, left to right: **Name** (sortable), **Email** (sortable), **Role** (coloured badge, e.g. Super Admin red, Owner purple, Contributor green), **Organization**, **Created At** (sortable), **2FA Status** (green \"Enabled\" / red \"Disabled\" pill), and an unlabelled actions column (a red trash/delete icon).\n - 8 users were visible and the table fit on one screen (no pagination control shown at this row count).\n- The bug, confirmed live: the **Organization column is blank for all 8 rows**. Not one organization name renders. The cell currently resolves a single `user.organizationId` to a name, which is empty or unresolved for these users, so nothing shows.\n\n### 2. Organization filter dropdown (existing filter pattern)\n\n- Navigation path: same screen, click the \"Organization\" dropdown.\n- What it shows (see `admin-users-org-filter-open.png`): a dropdown (a shadcn/radix Select) listing \"All\" (checked) followed by every organization: velora, voltkrone, nordbank, carrefour, o2, oyika, ueno, \"Sherpa Test Organization 7\", \"Sherpa Test Organization 6\", \"Sherpa Test Organization 5\", and more (the list scrolls). This confirms many organizations exist in the system, which is relevant to the 1 to 100+ memberships concern.\n\n### 3. User detail page (where the feature's data already exists)\n\n- Navigation path: click any row in the Users table, which routes to `/users/{id}` (example captured: `/users/d0a6f897-3ca9-465f-89bd-504f8a9b8278`, the \"Roeland Werring\" row). Note: in dev this route compiles on first hit, so the navigation needed a few seconds.\n- What it shows (see `user-detail-page-full.png` and the focused `user-detail-account-state-memberships.png`):\n - Cards for Personal Information, Contact Information, Address, Password, Two-Factor Authentication, and an **\"Account State\"** card.\n - The Account State card shows **Global Role** (a dropdown, e.g. \"Super Admin\"), **Account Status** rendered as the word \"Active\" next to a blue on/off toggle, and **\"Organization Memberships (1)\"** with the count in the heading.\n - Each membership renders as a row: the organization name (e.g. \"Sherpa Portal - Main CRM\") with a shield icon, the line \"Role: Owner Status: Active Joined: 4/17/2026\", and a green **\"Active\"** pill on the right.\n- This is the key finding: both concepts the feature wants in the table (a user-level active status, and a list of the user's organizations with per-membership active status) already exist here. The list view is simply not surfacing them.\n\n### 4. Organizations admin tab (existing \"Active\" badge pattern)\n\n- Navigation path: left sidebar > **Organizations** (`/admin?tab=organizations`).\n- What it shows (see `admin-organizations-tab-full.png`): a table with columns Name, Display Name, Portal Domain, Type (badge), Mode (badge), **Active** (a green \"Active\" pill), and Actions. This confirms the green \"Active\" pill is an established pattern in this admin section and is the natural style to reuse for the new user status column.\n\n## Where the feature attaches\n\nAll three changes attach to the CRM Users table at `/admin?tab=users` (`admin-users-tab-current-full.png`). Concretely:\n\n1. **Active organizations in the table (fixing the empty column).** Reuse the existing 4th column, \"Organization\", on the Users table. The membership data is already available in the app (proven by the \"Organization Memberships (N)\" list on the detail page, `user-detail-account-state-memberships.png`). Because aThe file /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md has been updated successfully. (file state is current in your context — no need to Read it back)
Report written to output_path. 9 screenshots saved in proof dir. Live findings: Organization column exists but renders empty for all users (the bug); no user active column and no active filter; detail page already has the Account Status toggle + "Organization Memberships (N)" list with green Active pills (the data/UX the feature surfaces into the table).
Browser research complete: report at /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md, 9 PNGs in /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof.
Report written to output_path. 9 screenshots saved in proof dir. Live findings: Organization column exists but renders empty for all users (the bug); no user active column and no active filter; detail page already has the Account Status toggle + "Organization Memberships (N)" list with green Active pills (the data/UX the feature surfaces into the table).
Browser research complete: report at /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md, 9 PNGs in /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof.
Read inputs first. 4 files parallel.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-ascent/skills/ticket-writing/SKILL.md"}1 --- 2 name: ticket-writing 3 description: "Author a world-class engineering ticket (GitHub issue) at Stripe/Cloudflare quality for both code and UX/UI. Use when drafting a new ticket from a short description plus codebase and live-product research. Triggers on: draft a ticket, write the issue, compose ticket, turn this into an issue." 4 --- 5 6 # Ticket Writing 7 8 Turn a short human description (plus the codebase findings and live-product screenshots gathered by the research agents) into one precise, self-contained engineering ticket. The bar is Stripe/Cloudflare: an engineer who has never seen the feature should be able to implement it end to end, and a reviewer should be able to verify it, without asking a single clarifying question. 9 10 You are NOT implementing. You are producing the issue body (Markdown) and the metadata block. Do not invent facts. Every concrete claim about existing code, endpoints, screens, or data shapes must come from the research inputs you were given. If something is unknown, write it as an explicit Open Question, never as a fabricated detail. 11 12 ## Hard rules 13 14 - Never use em-dashes anywhere. Use commas, periods, parentheses, or a hyphen for empty values. 15 - Ground every "what exists today" statement in the codebase-search output or a screenshot. Cite the file path or screen. 16 - No placeholders, no "TODO: figure out", no lorem. If you cannot fill a section truthfully, mark it Open Question and say why. 17 - Scope one shippable change. If the description implies several, state the split and pick the smallest coherent first ticket; list the rest under Out of Scope / Follow-ups. 18 - Write for the whole stack the feature touches (api, portal, embed, test), not just the backend. A user-facing feature with no UI section is incomplete. 19 20 ## Required structure (issue body, in this order) 21 22 1. **Title** (returned separately): imperative, specific, under ~70 chars. "Add per-outlet claim rules to the portal product editor", not "Claim rules". 23 24 2. **Summary** (2-4 sentences): what we are building and the user/business value. Plain language a PM and an engineer both understand. 25 26 3. **Context / Current behaviour**: what exists today, grounded in research. Name the real modules, routes, components, and data shapes (with file paths). Embed the relevant screenshots of the current UI. If the feature extends an existing flow, describe that flow as it actually is now. 27 28 4. **Proposed change**: the target behaviour. Break it down by surface: 29 - **API** (sherpa-api): endpoints (method + path), request/response shape, validation, persistence/migrations, auth/RLS, events. 30 - **Portal** (sherpa-portal): screens, components, states (empty/loading/error/success), navigation entry points, which existing components to reuse. 31 - **Embed** (sherpa-embed): customer-funnel surface, only if the feature reaches the funnel. 32 - **Data / migrations**: new tables/columns, RLS, backfills. 33 Be concrete about names. Prefer "reuse the existing `Select` from app/components/ui/select.tsx" over "add a dropdown". 34 35 5. **UX / UI specification**: this is first-class, not an afterthought. Cover layout, copy (exact button/label text), the design-system tokens and components to use (defer to the sherpa-design-system skill), responsive behaviour, accessibility (labels, focus, keyboard), and every state. Reference the current-UI screenshots so the engineer knows exactly where the new surface lives. 36 37 6. **Acceptance criteria**: a checklist of testable, binary statements. Each line starts with `- [ ]` and is verifiable in the real product (UI or API), not "works well". Include the negative/error cases. These are what the QA eval will check, write them so a browser test can confirm each one. 38 39 7. **Test plan**: what to add to sherpa-test (api and/or browser), and the key manual verification steps. 40 41 8. **Out of scope / Follow-ups**: explicitly list what this ticket does NOT do, and any natural next tickets. 42 43 9. **Open questions**: anything genuinely unresolved. Empty section is fine and preferred; do not manufacture questions. 44 45 10. **Dependencies**: if this builds on unmerged work, add a line `Depends-On: #<n>` (the pipeline parses this to branch from that ticket's branch). 46 47 ## Metadata you also produce 48 49 Return, alongside the body, a small JSON object the dashboard uses to pre-fill the create form: 50 - `title` 51 - `priority`: one of P0/P1/P2, with a one-line justification (P0 = outage/security/blocking, P1 = important, P2 = normal). 52 - `area`: the single best-fit `area:*` label from the provided allowed list (do not invent labels). 53 - `size`: XS/S/M/L/XL estimate with a one-line basis (only used for the Lens/Support boards). 54 - `dependsOn`: issue number or null. 55 - `acceptanceCount`: number of acceptance-criteria checkboxes (sanity signal). 56 57 ## Quality checklist before you finish 58 59 - Could a new engineer implement this without asking anything? If not, which section is thin? 60 - Is every "currently it does X" backed by a file path or screenshot? 61 - Are acceptance criteria each independently checkable in the running product? 62 - Is the UI section specific enough that two engineers would build the same screen? 63 - Zero em-dashes, zero placeholders, one coherent scope. 64
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-ascent/skills/sherpa-design-system/SKILL.md"}1 --- 2 name: sherpa-design-system 3 description: "Reference for the Sherpa portal/embed design system: tokens, components, theming, and UI conventions. Use when writing the UX/UI section of a ticket or building portal/embed screens so the result matches the existing product. Triggers on: design system, sherpa UI, portal styling, which component, gk tokens." 4 --- 5 6 # Sherpa Design System 7 8 How the Sherpa front-end is built today. Use this to specify UI in tickets and to build portal/embed screens that look native. Source of truth is the real code: `sherpa-portal/app/globals.css`, `sherpa-portal/tailwind.config.js`, and `sherpa-portal/app/components/ui/`. When in doubt, read those files, do not guess. 9 10 ## Stack 11 12 - **sherpa-portal**: Next.js 16 (App Router), React 19, Tailwind CSS v4, TypeScript strict. 13 - **sherpa-embed**: Vite + React 18, Tailwind. The customer funnel. Lighter than portal; reuse portal patterns where shared, but verify the embed component set separately. 14 - Tailwind v4 uses `@theme inline` and `@source inline(...)` in `globals.css` (there is no `safelist` in the config). Dynamically composed class names must be safelisted via `@source inline(...)`, or they get purged. 15 16 ## Tokens (shadcn-style HSL CSS variables) 17 18 Defined in `:root` (light) and `.dark` (dark) in `globals.css`, consumed as `hsl(var(--token))`. Never hardcode hex for these; use the token. 19 20 - Surfaces: `--background`, `--foreground`, `--card` / `--card-foreground`, `--popover` / `--popover-foreground`, `--muted` / `--muted-foreground`. 21 - Brand/intent: `--primary` (near-navy in light), `--secondary` (Sherpa orange, `hsl(19 84% 50%)`), `--accent`, `--destructive`, plus `--border`, `--input`, `--ring`. 22 - Radius: `--radius` = `0.5rem`; Tailwind maps `rounded-lg/md/sm` off it. Default to `rounded-md`. 23 - Brand blue scale (Tailwind `blue-*` is overridden in `tailwind.config.js`): primary `#1F6AB4` (blue-600), deep `#24496D` (blue-700), light `#EAF5FC` (blue-50). Use these for brand accents. 24 - **Per-organization dynamic theming**: `OrganizationProvider` sets `--primary-color`, `--portal-primary`, etc. at runtime. Customer-facing surfaces use the dynamic utilities (`bg-primary-dynamic`, `bg-brand-primary`, `bg-portal-primary`) so each org's brand applies. Do NOT hardcode brand color on customer-facing UI; use the dynamic token. Internal/admin chrome can use the static tokens. 25 26 ## Components 27 28 Use the existing primitives in `sherpa-portal/app/components/ui/` before building anything new. They are shadcn-derived, styled with `class-variance-authority` (cva), and composed with the `cn()` helper from `@/app/lib/utils`. Available: `button`, `input`, `textarea`, `richTextarea`, `select`, `multi-select`, `checkbox`, `switch`, `label`, `card`, `dialog`, `tabs`, `accordion`, `table`, `tooltip`, `badge`, `divider`, `splitButton`, `currencyDisplay`, `percentage`, `colorPicker`, `IconPicker`, `DynamicIcon`, `ExportDropdown`. 29 30 - **Button** (`button.tsx`): variants `default` (brand-dynamic fill), `outline`, `secondary`, `ghost`, `link`, `destructive`/`red`, `green`, `none`; sizes `default`/`sm`/`lg`. Always pick a variant, never restyle inline. 31 - Build forms from `label` + `input`/`select`/`switch`; wrap sections in `card`. Use `dialog` for modals, `table` for lists, `tabs` for sub-navigation, `badge` for status pills. 32 33 ## Conventions 34 35 - Fonts: Inter and Geist (sans + mono) are loaded in `app/layout.tsx` via CSS variables (`--font-inter`, `--font-geist-sans`). Body falls back to system sans. 36 - Dark mode: class-based (`darkMode: "class"`). Every new surface must read correctly in both themes; rely on the tokens, which already flip. 37 - Always specify the full set of states for any new surface: empty, loading, error, success, disabled. 38 - Accessibility: every input has a `label`; interactive elements are keyboard-reachable with a visible focus ring (`focus:ring-2 focus:ring-offset-2`); buttons get `cursor-pointer` (already enforced in base layer). 39 - i18n: portal user-facing strings go through the translation layer; do not hardcode copy that should be translatable (enums use the portal `t()`, dynamic DB strings come from sherpa-api LMT). 40 - Respect each directory's `AGENTS.md`; it is authoritative for its subtree. 41 - Never use em-dashes in UI copy or code; use commas, periods, parentheses, or a hyphen for empty values. 42 43 ## When writing a ticket's UI section 44 45 Name the exact components to reuse (with paths), the tokens for color/spacing/radius, the responsive and dark-mode behaviour, and every state. The goal: two engineers reading the ticket build the same screen. 46
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.search.md"}1 (codebase research produced no report) 2
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.browser.md"}1 (browser research produced no report) 2
Both research reports empty. Proof dir + real codebase = grounding source. I have read access to sherpa-portal. Check proof dir, locate portal, check for prior draft.
{"command":"ls -la /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.proof/ 2>/dev/null; echo \"---DRAFT---\"; ls -la /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.draft.md /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.meta.json 2>/dev/null; echo \"---PORTAL---\"; ls -d /Users/bakker/dev/gangkhar/sherpa-portal 2>/dev/null","description":"Check proof dir, prior draft, portal location"}total 0 drwxr-xr-x@ 2 bakker wheel 64 Jun 2 13:20 . drwxr-xr-x@ 12 bakker wheel 384 Jun 2 13:20 .. ---DRAFT--- ---PORTAL--- /Users/bakker/dev/gangkhar/sherpa-portal
Proof dir empty too. No research, no screenshots. Must ground in real code myself. Portal at /Users/bakker/dev/gangkhar/sherpa-portal. Explore admin users page.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-portal && echo \"=== admin route ===\" && fd -t f -E node_modules -E .next 'admin' app 2>/dev/null | head -40; echo \"=== AGENTS.md ===\" && fd -t f 'AGENTS.md' . -E node_modules 2>/dev/null | head -20","description":"Find admin route files and AGENTS.md"}=== admin route === app/components/AdminOnlyRoute.tsx app/types/admin.ts === AGENTS.md === ./AGENTS.md ./app/AGENTS.md ./app/lens/optimizations/commission/AGENTS.md ./app/lens/synthetic-client/AGENTS.md
{"command":"echo \"=== admin dirs ===\" && fd -t d 'admin' app -E node_modules 2>/dev/null; echo \"=== tab=users / users tab usage ===\" && rg -l \"tab.*users|UsersTab|usersTab|UsersTable\" app --type ts --type tsx -i 2>/dev/null | head -30","description":"Find admin directory and users tab references"}=== admin dirs === app/admin/ app/components/admin/ === tab=users / users tab usage ===
rg tsx type unknown. List admin dirs.
{"command":"echo \"=== app/admin ===\" && fd . app/admin -t f 2>/dev/null; echo \"=== app/components/admin ===\" && fd . app/components/admin -t f 2>/dev/null","description":"List admin page and component files"}=== app/admin === app/admin/integrations/[id]/page.tsx app/admin/page.tsx === app/components/admin === app/components/admin/AuditLogsTab.tsx app/components/admin/CategoryDialog.tsx app/components/admin/ConfirmDialog.tsx app/components/admin/DataModelTab.tsx app/components/admin/FieldDialog.tsx app/components/admin/InsuranceTypesTab.tsx app/components/admin/IntegrationsTab.tsx app/components/admin/ModulesTab.tsx app/components/admin/OrganizationImportDialog.tsx app/components/admin/OrganizationMembersDialog.tsx app/components/admin/OrganizationsTab.tsx app/components/admin/PartnerDetailView.tsx app/components/admin/PartnersTab.tsx app/components/admin/UrlPatternInput.tsx app/components/admin/UserCreateForm.tsx app/components/admin/UserCreatedSuccess.tsx app/components/admin/UsersTab.tsx app/components/admin/integrations/AuthConfigsTab.tsx app/components/admin/integrations/CallLogsTab.tsx app/components/admin/integrations/EnvironmentsTab.tsx app/components/admin/integrations/FieldMappingsEditor.tsx app/components/admin/integrations/RequestTemplatesTab.tsx app/components/admin/integrations/SmartSetupModal.tsx app/components/admin/integrations/TestTab.tsx app/components/admin/integrations/parsers.ts
Found UsersTab.tsx. Read it + admin page + admin types in parallel.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/UsersTab.tsx"}1 "use client";
2
3 import React, { useState, useEffect, useCallback } from "react";
4
5 import { useRouter } from "next/navigation";
6
7 import { ArrowUpDown, Search, X, PlusCircle, Trash2 } from "lucide-react";
8
9 import { useTranslation } from "@gangkharai/lmt/client";
10
11 import Pagination from "@/app/components/Pagination";
12 import { useDebounce } from "@/app/hooks/useDebounce";
13 import api from "@/app/src/utils/api";
14 import { toTitleCase } from "@/app/utils/stringUtils";
15 import { UserData } from "@/app/types/user";
16 import {
17 Select,
18 SelectContent,
19 SelectItem,
20 SelectTrigger,
21 SelectValue,
22 } from "@/app/components/ui/select";
23 import { Organization } from "@/app/types/admin";
24 import { Button } from "@/app/components/ui/button";
25 import UserCreateForm, { UserCreatedData } from "./UserCreateForm";
26 import UserCreatedSuccess from "./UserCreatedSuccess";
27 import {
28 Dialog,
29 DialogContent,
30 DialogDescription,
31 DialogFooter,
32 DialogHeader,
33 DialogTitle,
34 } from "@/app/components/ui/dialog";
35 import { showSuccessToast, showErrorToast } from "@/app/components/Toast";
36 import { useOrganization } from "@/app/components/OrganizationProvider";
37 import useAuthStore from "@/app/src/store/authStore";
38 import { Can } from "@/app/components/Can";
39 import { PERMISSIONS } from "@/app/types/permissions";
40
41 interface UserListResponse {
42 status: string;
43 data: {
44 users: UserData[];
45 currentPage: number;
46 totalPages: number;
47 totalUsers: number;
48 sortField: string;
49 sortOrder: string;
50 };
51 }
52
53 interface SortConfig {
54 field: string;
55 order: "asc" | "desc";
56 }
57
58 interface UsersTabProps {
59 userType?: "crm" | "customer";
60 title?: string;
61 description?: string;
62 searchPlaceholder?: string;
63 emptyMessage?: string;
64 showOrganizationFilter?: boolean;
65 showCreateButton?: boolean;
66 apiBasePath?: string;
67 routeBasePath?: string;
68 }
69
70 const getRoleColorClass = (role: string): string => {
71 switch (role) {
72 case "super_admin":
73 return "bg-red-100 text-red-800";
74 case "owner":
75 return "bg-purple-100 text-purple-800";
76 case "admin":
77 return "bg-blue-100 text-blue-800";
78 case "contributor":
79 return "bg-green-100 text-green-800";
80 case "viewer":
81 return "bg-gray-100 text-gray-800";
82 default:
83 return "bg-gray-100 text-gray-800";
84 }
85 };
86
87 const UsersTab: React.FC<UsersTabProps> = ({
88 userType = "crm",
89 title = "CRM Users",
90 description = "A list of all CRM users in the system",
91 searchPlaceholder = "Search CRM users by name or email...",
92 emptyMessage = "No CRM users found",
93 showOrganizationFilter = true,
94 showCreateButton = true,
95 apiBasePath = "/users",
96 routeBasePath = "/users",
97 }) => {
98 const t = useTranslation();
99 const router = useRouter();
100 const { organization } = useOrganization();
101 const drillDownOrgId = useAuthStore((s) => s.drillDownOrgId);
102 const [users, setUsers] = useState<UserData[]>([]);
103 const [loading, setLoading] = useState(true);
104 const [error, setError] = useState<string | React.ReactNode>("");
105 const [currentPage, setCurrentPage] = useState(1);
106 const [totalPages, setTotalPages] = useState(1);
107 const [sortConfig, setSortConfig] = useState<SortConfig>({
108 field: "lastName",
109 order: "desc",
110 });
111 const [searchTerm, setSearchTerm] = useState("");
112 const debouncedSearchTerm = useDebounce(searchTerm, 300);
113 const [organizations, setOrganizations] = useState<Organization[]>([]);
114 const [organizationFilter, setOrganizationFilter] = useState("all");
115 const [isCreatingUser, setIsCreatingUser] = useState(false);
116 const [createdUserData, setCreatedUserData] = useState<UserCreatedData | null>(null);
117 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
118 const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
119 const [isDeleting, setIsDeleting] = useState(false);
120
121 const getOrganizationName = (organizationId: string | undefined): string => {
122 if (!organizationId) return "";
123 const organization = organizations.find((org) => org.id === organizationId);
124 return organization?.displayName || "";
125 };
126
127 const fetchUsers = useCallback(async () => {
128 try {
129 setLoading(true);
130 setError("");
131
132 const effectiveOrgFilter = showOrganizationFilter
133 ? organizationFilter
134 : drillDownOrgId ?? "all";
135
136 const response = await api.get<UserListResponse>(
137 `${apiBasePath}?page=${currentPage}&limit=10&sortField=${sortConfig.field}&sortOrder=${sortConfig.order}&userType=${userType}${debouncedSearchTerm ? `&search=${encodeURIComponent(debouncedSearchTerm)}` : ""}${effectiveOrgFilter !== "all" ? `&organizationId=${encodeURIComponent(effectiveOrgFilter)}` : ""}`
138 );
139
140 // Filter out deleted users
141 const activeUsers = response.data.data.users.filter((user) => !user.deleted);
142 setUsers(activeUsers);
143 setTotalPages(response.data.data.totalPages);
144 } catch (error) {
145 setError(t("Failed to fetch users"));
146 console.error("Error fetching users:", error);
147 } finally {
148 setLoading(false);
149 }
150 }, [
151 currentPage,
152 sortConfig.field,
153 sortConfig.order,
154 debouncedSearchTerm,
155 organizationFilter,
156 userType,
157 showOrganizationFilter,
158 organization?.id,
159 apiBasePath,
160 drillDownOrgId,
161 ]);
162
163 useEffect(() => {
164 fetchUsers();
165 }, [fetchUsers]);
166
167 useEffect(() => {
168 const loadOrganizations = async () => {
169 try {
170 const res = await api.get<{ status: string; data?: Organization[] }>("/organizations");
171 setOrganizations(res.data.data || []);
172 } catch {}
173 };
174 void loadOrganizations();
175 }, []);
176
177 const handleSort = (field: string) => {
178 setSortConfig((prevConfig) => ({
179 field,
180 order: prevConfig.field === field && prevConfig.order === "asc" ? "desc" : "asc",
181 }));
182 setCurrentPage(1);
183 };
184
185 const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
186 setSearchTerm(event.target.value);
187 setCurrentPage(1);
188 };
189
190 const clearSearch = () => {
191 setSearchTerm("");
192 setCurrentPage(1);
193 };
194
195 const handleCreateUser = () => {
196 setIsCreatingUser(true);
197 setCreatedUserData(null);
198 };
199
200 const handleCancelCreate = () => {
201 setIsCreatingUser(false);
202 setCreatedUserData(null);
203 };
204
205 const handleUserCreated = (data: UserCreatedData) => {
206 setIsCreatingUser(false);
207 setCreatedUserData(data);
208 };
209
210 const handleBackToUsers = () => {
211 setCreatedUserData(null);
212 fetchUsers(); // Refresh the users list
213 };
214
215 const handleDeleteClick = (e: React.MouseEvent, user: UserData) => {
216 e.stopPropagation(); // Prevent row click navigation
217 setUserToDelete(user);
218 setDeleteConfirmOpen(true);
219 };
220
221 const handleConfirmDelete = async () => {
222 if (!userToDelete) return;
223
224 setIsDeleting(true);
225 try {
226 await api.delete(`${apiBasePath}/${userToDelete.id}`);
227 showSuccessToast(t("User deleted successfully"));
228 setDeleteConfirmOpen(false);
229 setUserToDelete(null);
230 fetchUsers(); // Refresh the list
231 } catch (error) {
232 console.error("Error deleting user:", error);
233 showErrorToast(t("Failed to delete user"));
234 } finally {
235 se{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/types/admin.ts"}1 export type DisableClaimAiAssessmentMode = "none" | "customer" | "all";
2
3 export type OrganizationType = "insurer" | "broker" | "affinity";
4
5 export interface Organization {
6 id: string;
7 name: string;
8 displayName?: string;
9 description?: string;
10 portalDomain: string;
11 portalUrl: string;
12 apiUrl?: string;
13 portalType: "customer" | "crm";
14 portalMode: "standalone" | "embedded";
15 active: boolean;
16 timezone?: string;
17 settings?: Record<string, unknown>;
18 disableClaimAiAssessment?: DisableClaimAiAssessmentMode;
19 disableClaimMessages?: boolean;
20 organizationType: OrganizationType;
21 parentOrganizationId: string | null;
22 disablePolicyMessages?: boolean;
23 createdAt: string;
24 updatedAt: string;
25 }
26
27 import { RoleLevel } from './permissions';
28
29 export type CreateOrganization = Omit<
30 Organization,
31 "id" | "createdAt" | "updatedAt" | "organizationType" | "parentOrganizationId"
32 > & {
33 organizationType?: OrganizationType;
34 parentOrganizationId?: string | null;
35 };
36 export type UpdateOrganization = Partial<CreateOrganization>;
37
38 export type ModuleCategory = "core" | "business" | "admin" | "analytics" | "integration";
39
40 export interface ModuleDef {
41 id: number;
42 key: string;
43 name: string;
44 description?: string;
45 icon?: string;
46 route: string;
47 category: ModuleCategory;
48 sortOrder?: number;
49 requiredRole?: RoleLevel;
50 active: boolean;
51 }
52
53 export type CreateModuleDef = Omit<ModuleDef, "id">;
54 export type UpdateModuleDef = Partial<CreateModuleDef>;
55
56 export interface Partner {
57 id: string;
58 name: string;
59 createdAt: string;
60 updatedAt: string;
61 }
62
63 export type CreatePartner = Omit<Partner, "id" | "createdAt" | "updatedAt">;
64 export type UpdatePartner = Partial<CreatePartner>;
65
66 export type PartnerEnvironmentType = "test" | "staging" | "production";
67
68 export interface PartnerEnvironment {
69 id: string;
70 partnerId: string;
71 environment: PartnerEnvironmentType;
72 baseUrl: string;
73 authEndpoint?: string;
74 tokenEndpoint?: string;
75 }
76
77 export type CreatePartnerEnvironment = Omit<PartnerEnvironment, "id">;
78 export type UpdatePartnerEnvironment = Partial<CreatePartnerEnvironment>;
79
80 export type AuthType =
81 | "client_credentials"
82 | "username_password"
83 | "api_key"
84 | "basic_auth"
85 | "oauth2_code"
86 | "custom";
87
88 export type TokenType = "bearer" | "basic" | "custom";
89
90 export interface PartnerAuthConfig {
91 id: string;
92 partnerEnvironmentId: string;
93 authType: AuthType;
94 tokenType: TokenType;
95 authorizationHeaderFormat?: string;
96 encryptedCredentials?: string;
97 active: boolean;
98 }
99
100 export interface CreatePartnerAuthConfig {
101 partnerEnvironmentId: string;
102 authType:
103 | "client_credentials"
104 | "username_password"
105 | "api_key"
106 | "basic_auth"
107 | "oauth2_code"
108 | "custom";
109 tokenType: "bearer" | "basic" | "custom";
110 authorizationHeaderFormat?: string;
111 active: boolean;
112 // Credential fields (based on authType)
113 username?: string;
114 password?: string;
115 clientId?: string;
116 clientSecret?: string;
117 apiKey?: string;
118 }
119
120 export interface UpdatePartnerAuthConfig {
121 authType?:
122 | "client_credentials"
123 | "username_password"
124 | "api_key"
125 | "basic_auth"
126 | "oauth2_code"
127 | "custom";
128 tokenType?: "bearer" | "basic" | "custom";
129 authorizationHeaderFormat?: string;
130 active?: boolean;
131 // Credential fields (only sent if updating)
132 username?: string;
133 password?: string;
134 clientId?: string;
135 clientSecret?: string;
136 apiKey?: string;
137 }
138
139 export interface OrganizationModuleAssignment {
140 id: number;
141 organizationId: string;
142 moduleId: number;
143 active: boolean;
144 settings?: Record<string, unknown>;
145 usePartnerApi?: boolean;
146 lastAccessed?: string;
147 }
148
149 export type CreateOrganizationModuleAssignment = Omit<OrganizationModuleAssignment, "id">;
150 export type UpdateOrganizationModuleAssignment = Partial<CreateOrganizationModuleAssignment>;
151
152 export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
153
154 export interface ModuleEndpoint {
155 id: number;
156 moduleId: number;
157 method: HttpMethod;
158 urlPattern: string;
159 controller?: string;
160 apiVersion?: string;
161 functionName?: string;
162 description?: string;
163 }
164
165 export type CreateModuleEndpoint = Omit<ModuleEndpoint, "id">;
166 export type UpdateModuleEndpoint = Partial<CreateModuleEndpoint>;
167
168 export interface PartnerEndpointMapping {
169 id: number;
170 partnerId: string;
171 moduleEndpointId: number;
172 urlPattern?: string;
173 active?: boolean;
174 executionOrder?: number;
175 excludeFromAutoLoading?: boolean;
176 httpMethod?: HttpMethod;
177 urlTemplate?: string;
178 requestBodyTemplate?: string | null;
179 responseMapping?: string | null;
180 dependsOnEndpoint?: number | null;
181 parameterTransformations?: string | null;
182 }
183
184 export type CreatePartnerEndpointMapping = Omit<PartnerEndpointMapping, "id">;
185 export type UpdatePartnerEndpointMapping = Partial<CreatePartnerEndpointMapping>;
186
187 export interface PartnerFieldMapping {
188 id: number;
189 partnerEndpointMappingId: number;
190 partnerFieldPath: string;
191 dataModelFieldId?: number;
192 customFieldName?: string;
193 transformationFunction?: string;
194 isRequired?: boolean;
195 defaultValue?: string;
196 enumMapping?: Record<string, string> | null;
197 direction?: "request" | "response";
198 }
199
200 export type CreatePartnerFieldMapping = Omit<PartnerFieldMapping, "id">;
201 export type UpdatePartnerFieldMapping = Partial<CreatePartnerFieldMapping>;
202
203 export interface ProductModulePartner {
204 id: number;
205 productId: number;
206 moduleId: number;
207 partnerId: string;
208 module?: ModuleDef;
209 partner?: Partner;
210 createdAt?: string;
211 updatedAt?: string;
212 }
213
214 export type CreateProductModulePartner = Omit<
215 ProductModulePartner,
216 "id" | "module" | "partner" | "createdAt" | "updatedAt"
217 >;
218 export type UpdateProductModulePartner = Partial<Pick<ProductModulePartner, "partnerId">>;
219
220 export interface PagedResponse<T> {
221 status: "success" | "error";
222 data?: {
223 items: T[];
224 total: number;
225 page: number;
226 pageSize: number;
227 };
228 message?: string;
229 }
230
231 export interface ListResponse<T> {
232 status: "success" | "error";
233 data?: T[];
234 message?: string;
235 }
236
237 // ─── External integrations (outbound third-party API framework) ──────────────
238
239 export type IntegrationCapability =
240 | "sanction_screening"
241 | "pep"
242 | "kyc"
243 | "adverse_media"
244 | "fraud_detection";
245
246 // Operations a capability supports. Today every compliance capability has a
247 // single 'check' operation; widen this when a capability gains more.
248 export const CAPABILITY_OPERATIONS: Record<IntegrationCapability, readonly string[]> = {
249 sanction_screening: ["check"],
250 pep: ["check"],
251 kyc: ["check"],
252 adverse_media: ["check"],
253 fraud_detection: ["check"],
254 };
255 export type IntegrationKind = "generic_rest";
256 export type IntegrationEnvironment = "test" | "staging" | "production";
257
258 export type IntegrationInvocationMode = "sync" | "async";
259
260 // Sensible per-capability defaults. Slow review-style providers default to
261 // async so the customer isn't blocked behind document review.
262 export const CAPABILITY_DEFAULT_INVOCATION_MODE: Record<IntegrationCapability, IntegrationInvocationMode> = {
263 sanction_{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/admin/page.tsx"}1 "use client";
2
3 import React from "react";
4
5 import { useSearchParams } from "next/navigation";
6
7 import { useTranslation } from "@gangkharai/lmt/client";
8
9 import AuditLogsTab from "@/app/components/admin/AuditLogsTab";
10 import DataModelTab from "@/app/components/admin/DataModelTab";
11 import InsuranceTypesTab from "@/app/components/admin/InsuranceTypesTab";
12 import IntegrationsTab from "@/app/components/admin/IntegrationsTab";
13 import ModulesTab from "@/app/components/admin/ModulesTab";
14 import OrganizationsTab from "@/app/components/admin/OrganizationsTab";
15 import PartnersTab from "@/app/components/admin/PartnersTab";
16 import UsersTab from "@/app/components/admin/UsersTab";
17 import { AdminOnlyRoute } from "@/app/components/AdminOnlyRoute";
18 import AppLayout from "@/app/components/AppLayout";
19
20 type AdminTab =
21 | "users"
22 | "organizations"
23 | "modules"
24 | "partners"
25 | "integrations"
26 | "insurance-types"
27 | "data-model"
28 | "audit-logs";
29
30 const AdminPage: React.FC = () => {
31 const searchParams = useSearchParams();
32 const activeTab = (searchParams.get("tab") as AdminTab | null) || "organizations";
33
34 return (
35 <AdminOnlyRoute>
36 <AppLayout>
37 {activeTab === "organizations" && <OrganizationsTab />}
38 {activeTab === "users" && <UsersTab />}
39 {activeTab === "modules" && <ModulesTab />}
40 {activeTab === "partners" && <PartnersTab />}
41 {activeTab === "integrations" && <IntegrationsTab />}
42 {activeTab === "insurance-types" && <InsuranceTypesTab />}
43 {activeTab === "data-model" && <DataModelTab />}
44 {activeTab === "audit-logs" && <AuditLogsTab />}
45 </AppLayout>
46 </AdminOnlyRoute>
47 );
48 };
49
50 export default AdminPage;
51 Now I understand current table. Columns: Name, Email, Role, Organization (single user.organizationId → name, line 434), Created At, 2FA Status, Actions. No active/inactive column, no active filter. Org filter Select exists (315-338) — reuse pattern. Need UserData shape + the API. Read user type, find API endpoint, check org membership dialog.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/types/user.ts"}1 // types.ts
2
3 export interface UserOrganizationMembership {
4 id: string;
5 name: string;
6 displayName?: string;
7 UserOrganization: {
8 role: string;
9 isActive: boolean;
10 createdAt: string;
11 };
12 }
13
14 export interface UserData {
15 id: string;
16 firstName: string;
17 lastName: string;
18 companyName?: string;
19 email: string;
20 phoneNumber: string;
21 country: string;
22 region: string;
23 address: string;
24 city: string;
25 postalCode: string;
26 role: string;
27 userType: string;
28 twoFactorMethod?: string;
29 twoFactorEnabled: boolean;
30 createdAt: string;
31 updatedAt: string;
32 deleted: boolean;
33 organizationId?: string;
34 organizationName?: string;
35 organization?: {
36 id: string;
37 name: string;
38 displayName?: string;
39 };
40 organizations?: UserOrganizationMembership[];
41 }
42
43 export interface AuthContextType {
44 user: UserData | null;
45 loading: boolean;
46 login: (accessToken: string, refreshToken: string, userData?: Partial<UserData>) => Promise<void>;
47 logout: () => void;
48 refreshToken: () => Promise<string | null>;
49 }
50
51 export type EditableSection = "personalInfo" | "contactInfo" | "address" | "password";
52
53 export interface PasswordRequirement {
54 regex: RegExp;
55 text: string;
56 }
57
58 export const passwordRequirements: PasswordRequirement[] = [
59 { regex: /.{8,}/, text: "At least 8 characters long" },
60 { regex: /[A-Z]/, text: "At least one uppercase letter" },
61 { regex: /[a-z]/, text: "At least one lowercase letter" },
62 { regex: /[0-9]/, text: "At least one number" },
63 { regex: /[@$!%*?&]/, text: "At least one special character (@$!%*?&)" },
64 ];
65
66 export interface PersonalInfoSectionProps {
67 userData: UserData;
68 onUpdate: (data: Partial<UserData>) => Promise<void>;
69 isLoading: boolean;
70 }
71
72 export interface ContactInfoSectionProps {
73 userData: UserData;
74 onUpdate: (data: Partial<UserData>) => Promise<void>;
75 isLoading: boolean;
76 }
77
78 export interface AddressSectionProps {
79 userData: UserData;
80 onUpdate: (data: Partial<UserData>) => Promise<void>;
81 isLoading: boolean;
82 }
83
84 export interface PasswordChangeSectionProps {
85 userId: string;
86 onSuccess: () => void;
87 onError: (message: string) => void;
88 }
89
90 export interface FormData {
91 firstName: string;
92 lastName: string;
93 companyName?: string;
94 email: string;
95 password: string;
96 phoneNumber: string;
97 country: string;
98 region: string;
99 address: string;
100 city: string;
101 postalCode: string;
102 }
103
104 export type LogoutSectionProps = Record<string, never>;
105 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal/app/components/admin/OrganizationMembersDialog.tsx"}1 "use client";
2
3 import React, { useState, useEffect, useCallback } from "react";
4
5 import { Plus, Trash2, User, Shield, Users, Search, Check, ChevronDown } from "lucide-react";
6
7 import { Button } from "../ui/button";
8 import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
9 import { Input } from "../ui/input";
10 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
11 import { organizationService } from "../../services/organizationService";
12 import { OrganizationMember } from "../../types/organization";
13 import { Organization } from "../../types/admin";
14 import { UserData } from "../../types/user";
15 import { showErrorToast, showSuccessToast } from "../Toast";
16 import ConfirmDialog from "./ConfirmDialog";
17 import { useDebounce } from "../../hooks/useDebounce";
18 import { ROLE_LEVELS } from "../../types/permissions";
19 import { toTitleCase } from "../../utils/stringUtils";
20 import { useLMT } from "@/lib/lmt/config";
21
22 const isHighPrivilegeRole = (role: string): boolean => {
23 return role === "super_admin" || role === "owner";
24 };
25
26 const formatRoleName = (role: string): string => {
27 return role.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
28 };
29
30 interface OrganizationMembersDialogProps {
31 organization: Organization | null;
32 isOpen: boolean;
33 onClose: () => void;
34 }
35
36 export function OrganizationMembersDialog({
37 organization,
38 isOpen,
39 onClose,
40 }: OrganizationMembersDialogProps) {
41 const { t } = useLMT();
42 const [members, setMembers] = useState<OrganizationMember[]>([]);
43 const [isLoading, setIsLoading] = useState(false);
44 const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
45 const [isInviting, setIsInviting] = useState(false);
46 const [selectedUser, setSelectedUser] = useState<UserData | null>(null);
47 const [selectedRole, setSelectedRole] = useState("contributor");
48 const [availableUsers, setAvailableUsers] = useState<UserData[]>([]);
49 const [userSearchTerm, setUserSearchTerm] = useState("");
50 const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
51 const [isLoadingUsers, setIsLoadingUsers] = useState(false);
52 const [updatingRoleUserIds, setUpdatingRoleUserIds] = useState<Set<string>>(new Set());
53 const debouncedUserSearch = useDebounce(userSearchTerm, 300);
54 const [confirmDialog, setConfirmDialog] = useState<{
55 isOpen: boolean;
56 title: string;
57 description: React.ReactNode;
58 onConfirm: () => void;
59 }>({
60 isOpen: false,
61 title: "",
62 description: "",
63 onConfirm: () => {},
64 });
65
66 const loadMembers = useCallback(async () => {
67 if (!organization) return;
68
69 setIsLoading(true);
70 try {
71 const response = await organizationService.getOrganizationMembers(organization.id);
72 if (response.status === "success" && response.data) {
73 setMembers(response.data);
74 } else {
75 showErrorToast(response.message || t("Failed to load organization members"));
76 }
77 } catch (error) {
78 console.error("Failed to load members:", error);
79 showErrorToast(t("Failed to load organization members"));
80 } finally {
81 setIsLoading(false);
82 }
83 }, [organization, t]);
84
85 const loadAvailableUsers = useCallback(async () => {
86 setIsLoadingUsers(true);
87 try {
88 const response = await organizationService.searchUsers(debouncedUserSearch);
89 if (response.status === "success" && response.data) {
90 // Filter out users who are already members
91 const memberUserIds = new Set(members.map((m) => m.userId).filter(Boolean));
92 const filteredUsers = response.data.filter(
93 (user) => !memberUserIds.has(user.id.toString())
94 );
95 setAvailableUsers(filteredUsers);
96 } else {
97 setAvailableUsers([]);
98 }
99 } catch (error) {
100 console.error("Failed to load users:", error);
101 setAvailableUsers([]);
102 } finally {
103 setIsLoadingUsers(false);
104 }
105 }, [debouncedUserSearch, members]);
106
107 useEffect(() => {
108 if (organization && isOpen) {
109 loadMembers();
110 }
111 }, [organization, isOpen, loadMembers]);
112
113 useEffect(() => {
114 if (isInviteDialogOpen) {
115 loadAvailableUsers();
116 }
117 }, [isInviteDialogOpen, debouncedUserSearch, loadAvailableUsers]);
118
119 const handleInviteUser = async () => {
120 if (!organization || !selectedUser) return;
121
122 setIsInviting(true);
123 try {
124 const response = await organizationService.inviteUser(organization.id, {
125 userId: selectedUser.id.toString(),
126 role: selectedRole,
127 });
128
129 if (response.status === "success") {
130 showSuccessToast(t("User added to organization successfully"));
131 setIsInviteDialogOpen(false);
132 setSelectedUser(null);
133 setSelectedRole("contributor");
134 setUserSearchTerm("");
135 loadMembers();
136 } else {
137 showErrorToast(response.message || t("Failed to add user to organization"));
138 }
139 } catch (error) {
140 console.error("Failed to invite user:", error);
141 showErrorToast(t("Failed to add user to organization"));
142 } finally {
143 setIsInviting(false);
144 }
145 };
146
147 const handleRemoveUser = async (userId: string, firstName: string, lastName: string) => {
148 if (!organization) return;
149
150 const userName = `${firstName} ${lastName}`;
151 setConfirmDialog({
152 isOpen: true,
153 title: t("Remove User"),
154 description: (
155 <>
156 {t("Are you sure you want to remove")} <strong>{userName}</strong> {t("from this organization? This action cannot be undone.")}
157 </>
158 ),
159 onConfirm: async () => {
160 try {
161 const response = await organizationService.removeUser(organization.id, userId);
162 if (response.status === "success") {
163 showSuccessToast(t("User removed successfully"));
164 loadMembers();
165 } else {
166 showErrorToast(response.message || t("Failed to remove user"));
167 }
168 } catch (error) {
169 console.error("Failed to remove user:", error);
170 showErrorToast(t("Failed to remove user"));
171 }
172 },
173 });
174 };
175
176 const handleUpdateRole = async (userId: string, newRole: string) => {
177 if (!organization) return;
178 setUpdatingRoleUserIds((prev) => new Set(prev).add(userId));
179 setMembers((prev) =>
180 prev.map((m) => (m.userId === userId ? { ...m, role: newRole } : m))
181 );
182 try {
183 const response = await organizationService.updateMemberRole(
184 organization.id,
185 userId,
186 newRole
187 );
188 if (response.status === "success") {
189 showSuccessToast(t("Role updated"));
190 } else {
191 showErrorToast(response.message || t("Failed to update role"));
192 loadMembers();
193 }
194 } catch (error) {
195 console.error("Failed to update role:", error);
196 const apiMessage =
197 (error as { response?: { data?: { error?: { message?: string } } } })
198 ?.response?.data?.error?.message;
199 showErrorToast(apiMessage || t("Failed to update role"));
200 loadMembers();
201 } finally {
202 setUpdatingRoleUserIds((prev) => {
203 const next = new Set(prev);
204 next.delete(userId);
205 return next;
206 });
207 }
208 };
209
210 const handleUserSelect = (user: UserData) => {
211 setSelec{"command":"cd /Users/bakker/dev/gangkhar && echo \"=== user route in sherpa-api ===\" && fd -t f -E node_modules . sherpa-api/src 2>/dev/null | rg -i 'user' | head -30","description":"Find user routes/controllers in sherpa-api"}=== user route in sherpa-api === sherpa-api/src/services/userService.ts sherpa-api/src/services/azureAdUserService.ts sherpa-api/src/routes/plus/userRoutes.ts sherpa-api/src/routes/userRoutes.ts sherpa-api/src/controllers/userController.ts sherpa-api/src/schemas/plus/user.schemas.ts sherpa-api/src/schemas/user.schemas.ts sherpa-api/src/db/migrations/2025.07.20T00.00.23.add-company-name-to-users.ts sherpa-api/src/db/migrations/2026.01.06T05.00.00.fix-user-organizations-rls-policy.ts sherpa-api/src/db/migrations/2025.07.20T00.00.22.create-user-social-auths.ts sherpa-api/src/db/migrations/2025.10.04T00.00.08.fix-user-email-unique-constraint.ts sherpa-api/src/db/migrations/2025.09.19T00.00.01.create-user-organizations.ts sherpa-api/src/db/migrations/2025.12.19T20.00.00.remove-userId-from-custom-forms-settings.ts sherpa-api/src/db/migrations/2026.02.06T00.00.01.recreate-api-test-user-for-sherpa-test.ts sherpa-api/src/db/migrations/2025.07.20T00.00.14.add-email-index-to-users.ts sherpa-api/src/db/migrations/2026.01.14T00.00.01.seed-portal-test-user.ts sherpa-api/src/db/migrations/2026.01.30T00.00.02.migrate-customers-from-users.ts sherpa-api/src/db/migrations/2025.10.16T20.00.00.drop-organization-id-from-users-final.ts sherpa-api/src/db/migrations/2025.10.04T00.00.02.make-user-fields-nullable.ts sherpa-api/src/db/migrations/2026.01.16T00.00.01.seed-e2e-test-user-for-sherpa-test.ts sherpa-api/src/db/migrations/2025.10.18T18.33.26.add-klimber-users-module-partner.ts sherpa-api/src/db/migrations/2025.12.03T00.00.02.change-user-role-enum.ts sherpa-api/src/db/migrations/2025.07.20T00.00.09.create-user.ts sherpa-api/src/db/migrations/2025.07.20T00.00.19.add-reset-token-to-users.ts sherpa-api/src/db/migrations/2025.09.21T19.00.00.remove-organization-id-from-users-table.ts sherpa-api/src/db/migrations/2025.09.19T00.00.03.remove-organization-id-from-users.ts sherpa-api/src/db/migrations/2025.10.04T00.00.01.add-external-client-id-to-users.ts sherpa-api/src/db/migrations/2025.12.03T00.00.03.simplify-user-permissions.ts sherpa-api/src/db/migrations/2026.04.02T00.00.01.migrate-user-id-integer-to-uuid.ts sherpa-api/src/db/migrations/2025.10.11T00.00.04.add-user-organizations-for-customers.ts
Big finding. UserData.organizations?: UserOrganizationMembership[] already exists (user.ts:40), each membership has UserOrganization.isActive (user.ts:3-12). And migrations show organizationId column was dropped from users table (2025.10.16...drop-organization-id-from-users-final.ts) — that's why the Organization column (getOrganizationName(user.organizationId), line 434) renders blank. Now verify the API actually returns organizations and whether a user-level active concept exists.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/routes/userRoutes.ts"}1 import { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
2 import { listCustomers, updateCustomer, deleteCustomer } from '@/controllers/customerController.js';
3 import { usersQuerystringSchema, userParamsSchema, updateUserBodySchema } from '@/schemas/user.schemas.js';
4
5 interface CustomersQuery {
6 page?: string;
7 limit?: string;
8 sortField?: string;
9 sortOrder?: string;
10 search?: string;
11 }
12
13 interface CustomerParams {
14 userId: string;
15 }
16
17 interface UpdateCustomerBody {
18 firstName?: string;
19 lastName?: string;
20 email?: string;
21 phoneNumber?: string;
22 companyName?: string;
23 country?: string;
24 region?: string;
25 address?: string;
26 city?: string;
27 postalCode?: string;
28 }
29
30 export const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
31 fastify.get<{ Querystring: CustomersQuery }>('/', {
32 preHandler: [fastify.authenticateScope(['users:read'])],
33 schema: {
34 description: 'Lists customers in the active organization, paginated. Despite the URL ("/v1/users"), this endpoint operates on the Customers table — it is the Sherpa Plus CRM admin "Users" view used by back-office staff to browse end-customers. Internal — used by the Sherpa Plus portal CRM screens; partner integrations should use the dedicated /v1/customers endpoints instead.',
35 tags: ['Sherpa Plus — Users'],
36 hide: true,
37 security: [{ clientCredentials: [] }],
38 querystring: usersQuerystringSchema,
39 response: {
40 200: {
41 type: 'object',
42 additionalProperties: true,
43 properties: {
44 status: { type: 'string', example: 'success' },
45 data: {
46 type: 'object',
47 additionalProperties: true,
48 properties: {
49 users: {
50 type: 'array',
51 items: { type: 'object', additionalProperties: true },
52 },
53 currentPage: { type: 'integer', example: 1 },
54 totalPages: { type: 'integer', example: 5 },
55 totalUsers: { type: 'integer', example: 100 },
56 sortField: { type: 'string', example: 'lastName' },
57 sortOrder: { type: 'string', example: 'ASC' },
58 },
59 },
60 },
61 },
62 },
63 },
64 }, listCustomers);
65
66 fastify.put<{ Params: CustomerParams; Body: UpdateCustomerBody }>('/:userId', {
67 preHandler: [fastify.authenticateScope(['users:write'])],
68 schema: {
69 description: 'Updates a single customer record from the Sherpa Plus CRM admin "Users" view. Despite the URL, this writes to the Customers table. Internal — used by the Sherpa Plus portal; partner integrations should use the dedicated /v1/customers endpoints instead.',
70 tags: ['Sherpa Plus — Users'],
71 hide: true,
72 security: [{ clientCredentials: [] }],
73 params: userParamsSchema,
74 body: updateUserBodySchema,
75 response: {
76 200: {
77 type: 'object',
78 additionalProperties: true,
79 properties: {
80 status: { type: 'string', example: 'success' },
81 data: {
82 type: 'object',
83 additionalProperties: true,
84 properties: {
85 id: { type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000' },
86 firstName: { type: 'string', example: 'Alex' },
87 lastName: { type: 'string', example: 'Stone' },
88 email: { type: 'string', format: 'email', example: '[email protected]' },
89 userType: { type: 'string', example: 'customer' },
90 role: { type: 'string', example: 'customer' },
91 },
92 },
93 },
94 },
95 },
96 },
97 }, async (request: FastifyRequest<{ Params: CustomerParams; Body: UpdateCustomerBody }>, reply: FastifyReply) => {
98 const modifiedRequest = {
99 ...request,
100 params: { id: request.params.userId }
101 } as unknown as FastifyRequest<{ Params: { id: string }; Body: UpdateCustomerBody }>;
102
103 return await updateCustomer(modifiedRequest, reply);
104 });
105
106 fastify.delete<{ Params: CustomerParams }>('/:userId', {
107 preHandler: [fastify.authenticateScope(['users:delete'])],
108 schema: {
109 description: 'Soft-deletes a customer record from the Sherpa Plus CRM admin "Users" view. Despite the URL, this targets the Customers table. Internal — used by the Sherpa Plus portal; partner integrations should use the dedicated /v1/customers endpoints instead.',
110 tags: ['Sherpa Plus — Users'],
111 hide: true,
112 security: [{ clientCredentials: [] }],
113 params: userParamsSchema,
114 response: {
115 200: {
116 type: 'object',
117 additionalProperties: true,
118 properties: {
119 status: { type: 'string', example: 'success' },
120 message: { type: 'string', example: 'Customer deleted successfully' },
121 },
122 },
123 },
124 },
125 }, async (request: FastifyRequest<{ Params: CustomerParams }>, reply: FastifyReply) => {
126 const modifiedRequest = {
127 ...request,
128 params: { id: request.params.userId }
129 } as unknown as FastifyRequest<{ Params: { id: string } }>;
130
131 return await deleteCustomer(modifiedRequest, reply);
132 });
133 };
134
135 {"command":"cd /Users/bakker/dev/gangkhar/sherpa-api/src && echo \"=== getUsers/list in controller ===\" && rg -n \"getUsers|listUsers|getAllUsers|organizations|isActive|\\.active|status\" controllers/userController.ts | head -40","description":"Find list handler + active/org refs in user controller"}=== getUsers/list in controller ===
73:export const getAllUsers = async (
168: isActive: true,
191: status: 'success',
223: as: 'organizations',
225: attributes: ['role', 'isActive', 'createdAt'],
226: where: { isActive: true },
259: const userOrgIds = user.organizations?.map((org) => org.id) || [];
287: status: 'success',
311: where: { isActive: true },
342: throw new StatusError(403, 'Missing permission to modify role, deleted status, or organization');
365: as: 'organizations',
366: through: { attributes: ['role', 'isActive', 'createdAt'] },
373: status: 'success',
457: isActive: true,
532: isActive: true,
540: as: 'organizations',
541: through: { attributes: ['role', 'isActive', 'createdAt'] },
548: status: 'success',
678: status: 'success',Important: this /users route calls listCustomers (customers table). But controller has getAllUsers (line 73) that includes organizations with isActive. Need to find which route the admin tab actually hits. Read plus userRoutes, the controller list handler, the query schema, and where getAllUsers mounts + portal api base path.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/routes/plus/userRoutes.ts"}1 import { FastifyInstance, FastifyPluginAsync } from 'fastify';
2
3 import {
4 getAllUsers,
5 getUserById,
6 updateUser,
7 deleteUser,
8 getProfile,
9 updateProfile,
10 createUser,
11 updateUserPermissions,
12 } from '../../controllers/userController.js';
13 import {
14 userIdParamsSchema,
15 updateProfileBodySchema,
16 getAllUsersQuerystringSchema,
17 createUserBodySchema,
18 updateUserBodySchema,
19 updateUserPermissionsBodySchema,
20 } from '../../schemas/plus/user.schemas.js';
21 import { PERMISSIONS } from '@/types/permissions.js';
22
23 const TAG = 'Sherpa Plus — Users';
24
25 export const userRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
26 await fastify.register(async function (fastify) {
27 fastify.addHook('preHandler', fastify.authenticate);
28
29 const userEnvelopeResponse = {
30 200: {
31 type: 'object',
32 properties: {
33 status: { type: 'string', example: 'success' },
34 data: {
35 type: 'object',
36 additionalProperties: true,
37 properties: {
38 user: {
39 type: 'object',
40 additionalProperties: true,
41 properties: {
42 id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' },
43 firstName: { type: 'string', example: 'Alex' },
44 lastName: { type: 'string', example: 'Stone' },
45 email: { type: 'string', example: '[email protected]' },
46 role: { type: 'string', example: 'super_admin' },
47 userType: { type: 'string', example: 'crm' },
48 authProvider: { type: 'string', example: 'azure-ad' },
49 phoneNumber: { type: ['string', 'null'], example: null },
50 companyName: { type: ['string', 'null'], example: null },
51 country: { type: ['string', 'null'], example: null },
52 region: { type: ['string', 'null'], example: null },
53 address: { type: ['string', 'null'], example: null },
54 city: { type: ['string', 'null'], example: null },
55 postalCode: { type: ['string', 'null'], example: null },
56 externalClientId: { type: ['string', 'null'], example: null },
57 azureObjectId: { type: ['string', 'null'], example: null },
58 azureTenantId: { type: ['string', 'null'], example: null },
59 azureRoles: { type: ['array', 'null'], items: { type: 'string' } },
60 twoFactorMethod: { type: ['string', 'null'], example: 'none' },
61 twoFactorEnabled: { type: 'boolean', example: false },
62 deleted: { type: 'boolean', example: false },
63 organizations: {
64 type: 'array',
65 items: { type: 'object', additionalProperties: true },
66 },
67 userPermissions: {
68 type: 'array',
69 items: { type: 'object', additionalProperties: true },
70 },
71 createdAt: { type: 'string', format: 'date-time' },
72 updatedAt: { type: 'string', format: 'date-time' },
73 },
74 },
75 },
76 },
77 },
78 },
79 } as const;
80
81 // Get current user profile
82 fastify.get(
83 '/profile',
84 {
85 schema: {
86 description:
87 'Returns the authenticated user profile, including the user record and their organization memberships. Internal endpoint used by the Sherpa Portal navigation and account screens. Requires user-bound credentials; client-only credentials without a user binding return 400.',
88 tags: [TAG],
89 security: [{ clientCredentials: [] }],
90 response: userEnvelopeResponse,
91 },
92 },
93 getProfile
94 );
95
96 // Update current user profile
97 fastify.patch(
98 '/profile',
99 {
100 schema: {
101 description:
102 'Updates the authenticated user\'s own profile fields (name, contact details, address). Cannot be used to change role, organization or deletion state — those require the users:update permission via the /:id endpoint. Returns the updated user envelope.',
103 tags: [TAG],
104 security: [{ clientCredentials: [] }],
105 body: updateProfileBodySchema,
106 response: userEnvelopeResponse,
107 },
108 },
109 updateProfile
110 );
111
112 // Get user by ID (admin only or own profile)
113 fastify.get(
114 '/:id',
115 {
116 schema: {
117 description:
118 'Looks up a user by ID. Users may always read their own record; CRM users may read customers in the same organization; otherwise the users:read permission is required. Includes the user\'s active organization memberships and explicit permission grants.',
119 tags: [TAG],
120 security: [{ clientCredentials: [] }],
121 params: userIdParamsSchema,
122 response: userEnvelopeResponse,
123 },
124 },
125 getUserById
126 );
127
128 // Get all users
129 fastify.get<{
130 Querystring: {
131 page?: string;
132 limit?: string;
133 search?: string;
134 sortField?: string;
135 sortOrder?: string;
136 role?: string;
137 userType?: string;
138 organizationId?: string;
139 };
140 }>(
141 '/',
142 {
143 schema: {
144 description:
145 'Lists CRM users in the platform with pagination, search and sort. CRM users without users:read are limited to viewing customers from their own organization; super-admins may scope by organizationId. Customer queries must use the /customers endpoint.',
146 tags: [TAG],
147 security: [{ clientCredentials: [] }],
148 querystring: getAllUsersQuerystringSchema,
149 response: {
150 200: {
151 type: 'object',
152 properties: {
153 status: { type: 'string', example: 'success' },
154 data: {
155 type: 'object',
156 additionalProperties: true,
157 properties: {
158 users: {
159 type: 'array',
160 items: { type: 'object', additionalProperties: true },
161 },
162 currentPage: { type: 'integer', example: 1 },
163 totalPages: { type: 'integer', example: 5 },
164 totalUsers: { type: 'integer', example: 100 },
165 sortField: { type: 'string', example: 'lastName' },
166 sortOrder: { type: 'string', example: 'ASC' },
167 },
168 },
169 },
170 },
171 },
172 },
173 },
174 getAllUsers
175 );
176
177 // Create user (admin only)
178 fastify.post<{
179 Body: {
180 firstName: string;
181 lastName: string;
182 email: string;
183 role?: string;
184 phoneNumber?: string;
185 companyName?: string;
186 country?: string;
187 region?: string;
188 address?: string;
189 city?: string;
190 postalCode?: string;
191 organizationId: string;
192 };
193 }>(
194 '/',
195 {
196 schema: {
197 description:
198 'Creates a CRM user and links them to the given organization. When Azure AD is enabled the user is provisioned via Azure AD invitation (or linked if{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api/src/controllers/userController.ts","offset":73,"limit":230}73 export const getAllUsers = async (
74 request: FastifyRequest<{ Querystring: UsersQuery }>,
75 _reply: FastifyReply
76 ) => {
77 void _reply;
78 const {
79 page,
80 limit,
81 sortField,
82 sortOrder,
83 search,
84 userType,
85 organizationId: queryOrganizationId,
86 } = request.query;
87 const requestingUserType = request.userAuth?.userType;
88 const requestingUserOrgId = request.userAuth?.organizationId;
89
90 const userPermissions = request.userAuth?.id && requestingUserOrgId
91 ? await getUserEffectivePermissions(request.userAuth.id, requestingUserOrgId)
92 : new Set<string>();
93 const canReadUsers = userPermissions.has(PERMISSIONS.users.read.value);
94
95 if (requestingUserType === 'customer') {
96 throw new StatusError(403, 'Customers cannot view user lists');
97 }
98
99 if (requestingUserType === 'crm') {
100 if (userType === 'crm' && !canReadUsers) {
101 throw new StatusError(403, 'Insufficient permissions to view CRM users');
102 }
103
104 if (userType === 'customer' || !userType) {
105 if (queryOrganizationId && queryOrganizationId !== requestingUserOrgId) {
106 throw new StatusError(403, 'CRM users can only view customers from their own organization');
107 }
108 }
109 }
110
111 const pageNum = page ? parseInt(page) : 1;
112 const limitNum = limit ? parseInt(limit) : 1000;
113 const offset = (pageNum - 1) * limitNum;
114 const field = sortField ?? 'lastName';
115 const order = sortOrder === 'asc' ? 'ASC' : 'DESC';
116 const searchTerm = search ? search.trim() : '';
117
118 if (userType === 'customer') {
119 throw new StatusError(400, 'Use /customers endpoint for customer queries');
120 }
121
122 const whereClause: UserWhereClause = {
123 deleted: false,
124 };
125
126 if (searchTerm) {
127 whereClause[Op.or] = [
128 where(fn('LOWER', fn('concat', col('firstName'), ' ', col('lastName'))), {
129 [Op.like]: `%${searchTerm.toLowerCase()}%`,
130 }),
131 where(fn('LOWER', col('email')), { [Op.like]: `%${searchTerm.toLowerCase()}%` }),
132 ];
133 }
134
135 if (userType === 'crm') {
136 whereClause.userType = 'crm';
137 } else if (requestingUserType === 'crm' && !canReadUsers) {
138 throw new StatusError(403, 'Insufficient permissions to view users');
139 }
140
141 let orderClause: Array<[string | ReturnType<typeof fn>, string] | [string, string]>;
142 if (['firstName', 'lastName', 'email'].includes(field)) {
143 orderClause = [[fn('LOWER', col(field)), order]];
144 } else {
145 orderClause = [[field, order]];
146 }
147
148 orderClause.push(['id', 'ASC']);
149
150 const queryOptions: FindOptions<Model> & { distinct?: boolean } = {
151 attributes: {
152 exclude: ['password', 'refreshToken', 'twoFactorSecret', 'resetToken', 'resetTokenExpiry'],
153 },
154 where: whereClause,
155 limit: limitNum,
156 offset,
157 order: orderClause,
158 };
159
160 const effectiveOrgId = queryOrganizationId;
161
162 if (effectiveOrgId) {
163 queryOptions.include = [
164 {
165 model: UserOrganization.raw,
166 where: {
167 organizationId: effectiveOrgId,
168 isActive: true,
169 },
170 required: true,
171 attributes: [],
172 },
173 ];
174 queryOptions.distinct = true;
175 }
176
177 const { count, rows: users } = await User.findAndCountAll(queryOptions);
178
179 const totalPages = Math.ceil(count / limitNum);
180
181 const responseData = {
182 users,
183 currentPage: pageNum,
184 totalPages,
185 totalUsers: count,
186 sortField: field,
187 sortOrder: order,
188 };
189
190 return {
191 status: 'success',
192 data: responseData,
193 };
194 };
195
196 export const getUserById = async (
197 request: FastifyRequest<{ Params: UserParams }>,
198 _reply: FastifyReply
199 ) => {
200 void _reply;
201 const params = request.params as unknown as Record<string, string>;
202 const paramName = Object.keys(params)[0];
203 const paramValue = Object.values(params)[0];
204
205 if (!paramValue) {
206 throw new StatusError(400, 'User ID is required');
207 }
208
209 const requestingUserId = request.userAuth?.id || request.ident?.user?.id;
210 const requestingUserRole = request.userAuth?.role || request.ident?.user?.role;
211 const requestingUserOrgId = request.userAuth?.organizationId || request.ident?.org?.id;
212 const isClientAuth =
213 request.auth?.method === 'client' ||
214 request.auth?.method === 'widget' ||
215 (request.auth?.method === 'bearer' && request.auth.bearer?.client !== null);
216
217 if (!requestingUserId && !isClientAuth) {
218 throw new StatusError(401, 'Authentication required');
219 }
220
221 const includeOptions = {
222 model: Organization,
223 as: 'organizations',
224 through: {
225 attributes: ['role', 'isActive', 'createdAt'],
226 where: { isActive: true },
227 },
228 attributes: ['id', 'displayName', 'name', 'description'],
229 };
230
231 const userPermissionIncludeOptions = {
232 model: UserPermission.raw,
233 as: 'userPermissions',
234 required: false,
235 attributes: ['id', 'permission', 'granted', 'organizationId'],
236 };
237
238 let user;
239
240 if (paramName === 'id') {
241 user = await User.findByPk(paramValue, {
242 attributes: { exclude: ['password'] },
243 include: [includeOptions, userPermissionIncludeOptions],
244 });
245 } else {
246 user = await User.findOne({
247 where: {
248 [paramName]: paramValue,
249 },
250 attributes: { exclude: ['password'] },
251 include: [includeOptions, userPermissionIncludeOptions],
252 });
253 }
254
255 if (!user) {
256 throw new StatusError(404, 'User not found');
257 }
258
259 const userOrgIds = user.organizations?.map((org) => org.id) || [];
260 const isSameOrg = !!requestingUserOrgId && userOrgIds.includes(requestingUserOrgId);
261
262 if (isClientAuth) {
263 if (!isSameOrg) {
264 throw new StatusError(403, 'Access denied');
265 }
266 } else {
267 if (!requestingUserId) {
268 throw new StatusError(401, 'Authentication required');
269 }
270 const isOwnProfile = user.id === requestingUserId;
271 const requestingUserType = request.userAuth?.userType || request.ident?.user?.userType;
272 const isCrmUser = requestingUserType === 'crm';
273 const canViewCustomer = isCrmUser && user.userType === 'customer' && isSameOrg;
274
275 if (!isOwnProfile && !canViewCustomer) {
276 if (!requestingUserOrgId) {
277 throw new StatusError(403, 'Access denied');
278 }
279 const userPermissions = await getUserEffectivePermissions(requestingUserId, requestingUserOrgId);
280 if (!userPermissions.has(PERMISSIONS.users.read.value)) {
281 throw new StatusError(403, 'Access denied');
282 }
283 }
284 }
285
286 return {
287 status: 'success',
288 data: { user },
289 };
290 };
291
292 export const updateUser = async (
293 request: FastifyRequest<{ Params: UserParams; Body: UpdateUserBody }>,
294 _reply: FastifyReply
295 ) => {
296 void _reply;
297 const { id } = request.params;
298 const requestingUserId = request.userAuth?.id;
299 const requestingUserType = request.userAuth?.userType;
300 const requestingUserOrgId = request.userAuth?.organizationId;
301
302 if (!requestingUserId) {{"command":"echo \"=== getAllUsers registration ===\" && rg -n \"getAllUsers\" --glob '*.ts' routes/ | head; echo \"=== usersQuerystringSchema ===\" && rg -n \"usersQuerystringSchema\" -A 30 schemas/user.schemas.ts | head -60","description":"Find getAllUsers route + query schema"}=== getAllUsers registration ===
routes/plus/userRoutes.ts:4: getAllUsers,
routes/plus/userRoutes.ts:16: getAllUsersQuerystringSchema,
routes/plus/userRoutes.ts:148: querystring: getAllUsersQuerystringSchema,
routes/plus/userRoutes.ts:174: getAllUsers
=== usersQuerystringSchema ===
7:export const usersQuerystringSchema = Type.Object({
8- page: Type.Optional(Type.String({ description: 'Page number for pagination', example: '1' })),
9- limit: Type.Optional(Type.String({ description: 'Number of items per page', example: '20' })),
10- search: Type.Optional(Type.String({ description: 'Search term to filter users by name or email', example: 'alex' })),
11- sortField: Type.Optional(Type.String({ description: 'Field to sort by (e.g., firstName, lastName, email)', example: 'lastName' })),
12- sortOrder: Type.Optional(Type.Union([Type.Literal('asc'), Type.Literal('desc')], { description: 'Sort order: ascending or descending', example: 'asc' })),
13-});
14-
15-export const userParamsSchema = Type.Object({
16- userId: Type.String({ description: 'Unique identifier of the user', example: '550e8400-e29b-41d4-a716-446655440000' }),
17-});
18-
19-export const updateUserBodySchema = Type.Object({
20- firstName: Type.Optional(Type.String({ description: 'User\'s first name', example: 'Alex' })),
21- lastName: Type.Optional(Type.String({ description: 'User\'s last name', example: 'Stone' })),
22- email: Type.Optional(Type.String({ format: 'email', description: 'User\'s email address', example: '[email protected]' })),
23- phoneNumber: Type.Optional(Type.String({ description: 'User\'s phone number', example: '+34600123456' })),
24- companyName: Type.Optional(Type.String({ description: 'User\'s company name', example: 'Acme Insurance Partners' })),
25- country: Type.Optional(Type.String({ description: 'User\'s country', example: 'ES' })),
26- region: Type.Optional(Type.String({ description: 'User\'s region or state', example: 'Catalonia' })),
27- address: Type.Optional(Type.String({ description: 'User\'s street address', example: 'Carrer de Mallorca 401' })),
28- city: Type.Optional(Type.String({ description: 'User\'s city', example: 'Barcelona' })),
29- postalCode: Type.Optional(Type.String({ description: 'User\'s postal code', example: '08013' })),
30-});Bug fully confirmed. getAllUsers (the admin tab handler, plus/userRoutes.ts:141) only includes organizations when organizationId is passed, and even then with attributes: [] (controller 162-175) — so list rows never carry memberships. Meanwhile getUserById DOES include them (221-229). Plus organizationId column was dropped. Now confirm: no user-level active column exists, the plus query schema params, route mount prefixes, and portal api baseURL.
{"command":"echo \"=== User model active/status cols ===\" && fd -t f 'User.ts|user.ts' models/ 2>/dev/null; rg -n \"isActive|\\bactive\\b|status|disabled|suspended|deleted\" models/User*.ts 2>/dev/null | head -20; echo \"=== getAllUsersQuerystringSchema ===\" && rg -n \"getAllUsersQuerystringSchema\" -A 16 schemas/plus/user.schemas.ts; echo \"=== UserOrganization model ===\" && rg -n \"isActive|active\" models/UserOrganization*.ts 2>/dev/null | head","description":"Check User model for active col, plus query schema, UserOrganization"}=== User model active/status cols ===
models/User.ts
models/User.ts:165: declare deleted: boolean;
=== getAllUsersQuerystringSchema ===
24:export const getAllUsersQuerystringSchema = Type.Object({
25- page: Type.Optional(Type.String({ description: 'Page number for pagination', example: '1' })),
26- limit: Type.Optional(Type.String({ description: 'Number of items per page', example: '20' })),
27- search: Type.Optional(Type.String({ description: 'Search term to filter users by name or email', example: 'alex.stone' })),
28- sortField: Type.Optional(Type.String({ description: 'Field to sort by (e.g., firstName, lastName, email)', example: 'lastName' })),
29- sortOrder: Type.Optional(Type.String({ enum: ['asc', 'desc'], description: 'Sort order: ascending or descending', example: 'asc' })),
30- role: Type.Optional(Type.String({ enum: [...ROLE_LEVELS], description: 'Filter by user role', example: ROLE_LEVELS[0] })),
31- userType: Type.Optional(Type.String({ enum: ['crm', 'customer'], description: 'Filter by user type', example: 'crm' })),
32- organizationId: Type.Optional(Type.String({ description: 'Filter by organization ID', example: '550e8400-e29b-41d4-a716-446655440000' })),
33-});
34-
35-export const createUserBodySchema = Type.Object({
36- firstName: Type.String({ description: 'User\'s first name', example: 'Alex' }),
37- lastName: Type.String({ description: 'User\'s last name', example: 'Stone' }),
38- email: Type.String({ format: 'email', description: 'User\'s email address', example: '[email protected]' }),
39- role: Type.Optional(Type.String({ enum: [...ROLE_LEVELS], description: 'User role', example: ROLE_LEVELS[0] })),
40- phoneNumber: Type.Optional(Type.String({ description: 'User\'s phone number', example: '+34 600 123 456' })),
=== UserOrganization model ==={"command":"cd /Users/bakker/dev/gangkhar && echo \"=== plus userRoutes mount prefix ===\" && rg -rn \"plus/userRoutes|userRoutes\" sherpa-api/src --glob '*.ts' | rg -i \"register|prefix|import.*userRoutes\" | head; echo \"=== portal api baseURL ===\" && rg -n \"baseURL|API_URL|/v1\" sherpa-portal/app/src/utils/api.ts 2>/dev/null | head -20","description":"Find route mount prefix and portal api baseURL"}=== plus userRoutes mount prefix ===
sherpa-api/src/routes/plus/index.ts: await fastify.register(n, { prefix: '/users' });
sherpa-api/src/routes.ts: await app.register(n, { prefix: '/v1/users' });
=== portal api baseURL ===
15:// INVARIANT: every entry MUST start with `/v1/`. The request interceptor
16:// rewrites legacy `/auth/...` and bare paths into `/v1/...` BEFORE this list
17:// is consulted, so any non-`/v1/` entry here is unreachable and indicates a
20: "/v1/auth/login",
21: "/v1/auth/customer/login",
22: "/v1/auth/register",
23: "/v1/auth/forgot-password",
24: "/v1/auth/refresh-token",
25: "/v1/auth/customer/refresh-token",
26: "/v1/organization",
27: "/v1/health/self",
33: if (!path.startsWith("/v1/")) {
35: `NO_AUTH_PATHS invariant violated: "${path}" must start with "/v1/". ` +
36: `The request interceptor only sees rewritten /v1/* URLs.`,
56: baseURL: getSherpaApiUrl(),
120: ? `${getSherpaApiUrl()}/v1/auth/customer/refresh-token`
121: : `${getSherpaApiUrl()}/v1/auth/refresh-token`;
187: if (!ensured.startsWith("/v1/")) {
190: config.url = `/v1${ensured}`;
192: config.url = isAdmin ? `/v1/plus${ensured}` : `/v1${ensured}`;All grounded. Confirmed:
getAllUsers (api.ts:192 admin prefix /v1/plus; plus/index mounts userRoutes at /users).User.ts:165 only has deleted. "Active" must derive from UserOrganization.isActive memberships.Quick confirm of plus mount prefix, then write both artifacts.
{"command":"cd /Users/bakker/dev/gangkhar/sherpa-api/src && rg -n \"prefix:\\s*'/v1/plus'|prefix:\\s*\\\"/v1/plus\\\"|plus/index|plusRoutes\" routes.ts routes/index.ts 2>/dev/null | head; echo \"=== isAdmin def ===\"; rg -n \"isAdmin\" /Users/bakker/dev/gangkhar/sherpa-portal/app/src/utils/api.ts | head","description":"Confirm /v1/plus mount and isAdmin"}routes.ts:111:import { plusRoutes } from '@/routes/plus/index.js';
routes.ts:244: await app.register(plusRoutes, { prefix: '/v1/plus' });
=== isAdmin def ===
188: const isAdmin = config.sherpaAdmin !== false;
192: config.url = isAdmin ? `/v1/plus${ensured}` : `/v1${ensured}`;Confirmed: GET /v1/plus/users → getAllUsers (routes.ts:244 mounts plus at /v1/plus). Everything grounded. Writing both artifacts now (ticket in normal prose, no em-dashes).
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.draft.md","content":"## Summary\n\nThe admin Users table (`/admin?tab=users`) is meant to show which organizations a CRM user belongs to, but the Organization column renders blank for every user. The list endpoint never returns organization memberships, and the column reads a `user.organizationId` field that no longer exists after the multi-organization migration. This ticket fixes that by returning each user's active organization memberships from the list endpoint, rendering them in the table (capped so a user in many organizations does not overflow the row), adding an Active/Inactive status column, and adding a status filter above the table. Active/Inactive is derived from organization membership, since users have no standalone active flag.\n\n## Context / Current behaviour\n\nThe admin page renders `<UsersTab />` when `tab=users` (`sherpa-portal/app/admin/page.tsx:38`). The table is defined in `sherpa-portal/app/components/admin/UsersTab.tsx`.\n\nCurrent columns (`UsersTab.tsx:344-398`): Name, Email, Role, Organization, Created At, 2FA Status, Actions.\n\nTwo concrete defects and gaps:\n\n1. **Organization column is always blank.** The cell renders `getOrganizationName(user.organizationId)` (`UsersTab.tsx:434`), which looks up a single `organizationId` against the org list. The `organizationId` column was dropped from the users table during the multi-org migration (`sherpa-api/src/db/migrations/2025.10.16T20.00.00.drop-organization-id-from-users-final.ts`; memberships now live in the `user_organizations` join table created by `2025.09.19T00.00.01.create-user-organizations.ts`). So `user.organizationId` is undefined and the cell is empty. Separately, the list endpoint `getAllUsers` (`sherpa-api/src/controllers/userController.ts:73`) only joins `user_organizations` when an `organizationId` filter is passed, and even then with `attributes: []` (`userController.ts:162-175`), so the returned user rows carry no memberships at all. By contrast `getUserById` does include them (`userController.ts:221-229`), which is why a single user's detail page can show organizations while the list cannot.\n\n2. **No active/inactive status is shown.** There is no user-level active flag: the `User` model has only `deleted` (`sherpa-api/src/models/User.ts:165`), no `active`/`status` column. The only activity signal is per-membership `UserOrganization.isActive` (used in `getUserById` at `userController.ts:225` and typed in the portal as `UserOrganizationMembership.UserOrganization.isActive`, `sherpa-portal/app/types/user.ts:3-12`).\n\n3. **No status filter.** The table has a search box and an Organization filter Select (`UsersTab.tsx:315-338`), but nothing to filter by active vs inactive. The list query schema (`sherpa-api/src/schemas/plus/user.schemas.ts:24-33`) supports `page`, `limit`, `search`, `sortField`, `sortOrder`, `role`, `userType`, `organizationId`, but no status parameter.\n\nEndpoint path: the portal admin client rewrites admin calls to the `/v1/plus` prefix (`sherpa-portal/app/src/utils/api.ts:192`), and the plus router mounts user routes at `/users` (`sherpa-api/src/routes/plus/index.ts`, registered at `/v1/plus` in `sherpa-api/src/routes.ts:244`). So the table loads from **GET /v1/plus/users** (`getAllUsers`, route `sherpa-api/src/routes/plus/userRoutes.ts:141`).\n\nThe `UserData` type already declares `organizations?: UserOrganizationMembership[]` (`sherpa-portal/app/types/user.ts:40`), so the client type is ready; the data is simply never sent.\n\nNo live screenshots were captured for this draft; current behaviour above is grounded in source. See Open questions.\n\n## Proposed change\n\n### API (sherpa-api)\n\nEndpoint: **GET /v1/plus/users** (`getAllUsers`, `sherpa-api/src/controllers/userController.ts:73`).\n\n1. **Always return active memberships.** Add an `organizations` include to the `queryOptions` built at `userController.ts:150-158`, mirroring `getUserById` (`userController.ts:221-229`): include the `Organization` association `as: 'organizations'` with `through: { attributes: ['role', 'isActive', 'createdAt'], where: { isActive: true } }` and `attributes: ['id', 'displayName', 'name']`. Use `separate: true` on this include so the association is fetched in its own query and does not multiply rows or corrupt the `findAndCountAll` total used for pagination. The endpoint contract becomes: each user row includes an `organizations` array of that user's active memberships (empty array if none).\n\n2. **Add a `status` filter.** Extend `getAllUsersQuerystringSchema` (`sherpa-api/src/schemas/plus/user.schemas.ts:24`) and the route Querystring type (`sherpa-api/src/routes/plus/userRoutes.ts:129-139`) with `status: 'active' | 'inactive' | 'all'` (optional, default `all`). In `getAllUsers`, when `status === 'active'`, constrain the user `whereClause` (`userController.ts:122`) so `id` is in the set of users that have at least one `user_organizations` row with `isActive = true` (a `literal` subquery with `Op.in`). When `status === 'inactive'`, use the complement (`Op.notIn` of the same subquery), which selects users with zero active memberships. `all` or absent applies no status constraint. This must compose with the existing `organizationId` and `search` filters and with pagination (the `totalUsers`/`totalPages` counts must reflect the filter).\n\n3. No new table or column is required. The `deleted: false` constraint (`userController.ts:123`) is unchanged; soft-deleted users stay excluded.\n\n### Portal (sherpa-portal)\n\nFile: `sherpa-portal/app/components/admin/UsersTab.tsx`.\n\n1. **Organizations column** (replaces the broken Organization column). Rename the header at `UsersTab.tsx:377` from `t(\"Organization\")` to `t(\"Organizations\")`. Replace the cell body at `UsersTab.tsx:433-435` with a render of `user.organizations` (the active memberships now returned by the API): show up to 2 organization names as chips, then a `+{n} more`File created successfully at: /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.draft.md (file state is current in your context — no need to Read it back)
14 acceptance checkboxes. Writing metadata.
{"file_path":"/tmp/ticket-automation-logs/drafts/dmpx30poj1tus.meta.json","content":"{\n \"title\": \"Show organizations and active status in admin Users table\",\n \"priority\": \"P2\",\n \"priorityReason\": \"Admin-only visibility gap; org membership is still viewable per user on the detail page, so a workaround exists and operations are not blocked.\",\n \"area\": \"area:portal\",\n \"size\": \"M\",\n \"sizeReason\": \"API membership include plus a new status subquery filter, two portal table columns with overflow and tooltip, and API and browser tests.\",\n \"dependsOn\": null,\n \"acceptanceCount\": 14,\n \"screenshots\": []\n}\n"}File created successfully at: /tmp/ticket-automation-logs/drafts/dmpx30poj1tus.meta.json (file state is current in your context — no need to Read it back)
Done. Both files written.
draft.md: ticket grounded in real code (research reports were empty, proof dir empty — grounded via sherpa-portal + sherpa-api source instead). Root cause nailed: getAllUsers (userController.ts:73) never returns memberships, and organizationId column was dropped (migration 2025.10.16) so the column reads a dead field. Fix spans API (membership include + status filter on GET /v1/plus/users) and portal (UsersTab.tsx Organizations column with 2-chip + +n more tooltip, Status pill, status filter, colSpan 7→8). 14 acceptance criteria.meta.json: P2, area:portal, size M, 14 criteria, screenshots [].One judgment call flagged in Open questions: no user-level active flag exists (User.ts:165 only deleted), so "active" = has ≥1 active org membership. Needs human confirm.
Done. Both files written.
draft.md: ticket grounded in real code (research reports were empty, proof dir empty — grounded via sherpa-portal + sherpa-api source instead). Root cause nailed: getAllUsers (userController.ts:73) never returns memberships, and organizationId column was dropped (migration 2025.10.16) so the column reads a dead field. Fix spans API (membership include + status filter on GET /v1/plus/users) and portal (UsersTab.tsx Organizations column with 2-chip + +n more tooltip, Status pill, status filter, colSpan 7→8). 14 acceptance criteria.meta.json: P2, area:portal, size M, 14 criteria, screenshots [].One judgment call flagged in Open questions: no user-level active flag exists (User.ts:165 only deleted), so "active" = has ≥1 active org membership. Needs human confirm.