From 08f46d416395954190e706c888111cda424fce40 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:52:24 +0530 Subject: [PATCH 1/7] Add usePatchUserMe hook to update profile Introduce a new React hook (apps/customer-portal/webapp/src/api/usePatchUserMe.ts) that provides a react-query mutation for PATCH /users/me. The hook uses Asgardeo auth and an auth-enabled fetch client, validates sign-in state and backend base URL, serializes the request body, parses and surfaces server error messages, and invalidates the "userDetails" query on success. --- .../webapp/src/api/usePatchUserMe.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 apps/customer-portal/webapp/src/api/usePatchUserMe.ts diff --git a/apps/customer-portal/webapp/src/api/usePatchUserMe.ts b/apps/customer-portal/webapp/src/api/usePatchUserMe.ts new file mode 100644 index 0000000000..c77ec96182 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/usePatchUserMe.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + useMutation, + useQueryClient, + type UseMutationResult, +} from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { useAuthApiClient } from "@api/useAuthApiClient"; +import { useLogger } from "@hooks/useLogger"; +import type { PatchUserMeRequest } from "@models/requests"; + +/** + * Hook to update current user profile (PATCH /users/me). + * Only pass changed fields in the request body. + * + * @returns {UseMutationResult} Mutation result. + */ +export function usePatchUserMe(): UseMutationResult< + void, + Error, + PatchUserMeRequest +> { + const logger = useLogger(); + const queryClient = useQueryClient(); + const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const authFetch = useAuthApiClient(); + + return useMutation({ + mutationFn: async (payload: PatchUserMeRequest): Promise => { + logger.debug("[usePatchUserMe] Request payload:", payload); + + if (!isSignedIn || isAuthLoading) { + throw new Error("User must be signed in to update profile"); + } + + const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; + if (!baseUrl) { + throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); + } + + const requestUrl = `${baseUrl}/users/me`; + const response = await authFetch(requestUrl, { + method: "PATCH", + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + let errorMessage = `Error updating profile: ${response.status} ${response.statusText}`; + try { + const json = JSON.parse(text) as { message?: string }; + if (typeof json.message === "string") { + errorMessage = json.message; + } else if (text) { + errorMessage += ` - ${text}`; + } + } catch { + if (text) errorMessage += ` - ${text}`; + } + throw new Error(errorMessage); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["userDetails"] }); + }, + }); +} From b00e81f0129bbf4f49e1be1e2bd72a1487a18e5b Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:52:43 +0530 Subject: [PATCH 2/7] Add Profile menu item and modal Add a 'Profile' item to the header user menu and render a UserProfileModal when selected. Introduces useState to manage the modal open state, imports the User icon and UserProfileModal, and wraps the UserMenu in a fragment to include the modal alongside the menu. Existing logout behavior remains unchanged. --- .../components/common/header/UserProfile.tsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/common/header/UserProfile.tsx b/apps/customer-portal/webapp/src/components/common/header/UserProfile.tsx index 68688c8afe..c01615d636 100644 --- a/apps/customer-portal/webapp/src/components/common/header/UserProfile.tsx +++ b/apps/customer-portal/webapp/src/components/common/header/UserProfile.tsx @@ -16,11 +16,13 @@ import { Skeleton, UserMenu } from "@wso2/oxygen-ui"; import type { JSX } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router"; import { useAsgardeo } from "@asgardeo/react"; -import { LogOut } from "@wso2/oxygen-ui-icons-react"; +import { LogOut, User } from "@wso2/oxygen-ui-icons-react"; import useGetUserDetails from "@api/useGetUserDetails"; import { useLogger } from "@/hooks/useLogger"; +import UserProfileModal from "@components/common/header/UserProfileModal"; /** * User profile component. @@ -29,6 +31,7 @@ import { useLogger } from "@/hooks/useLogger"; */ export default function UserProfile(): JSX.Element { const navigate = useNavigate(); + const [profileModalOpen, setProfileModalOpen] = useState(false); const { signOut, isLoading: isAuthLoading, isSignedIn } = useAsgardeo(); const { data: userDetails, isLoading, isError } = useGetUserDetails(); const logger = useLogger(); @@ -60,16 +63,27 @@ export default function UserProfile(): JSX.Element { const email = isError ? "--" : userDetails?.email || "--"; return ( - - - + <> + + + - - } - label="Log out" - onClick={handleLogout} + + } + label="Profile" + onClick={() => setProfileModalOpen(true)} + /> + } + label="Log out" + onClick={handleLogout} + /> + + setProfileModalOpen(false)} /> - + ); } From e08c9dc1fe51d9db7b74fe3ce8cda6a1e514926a Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:54:10 +0530 Subject: [PATCH 3/7] Add UserProfileModal component Introduce a new UserProfileModal React component that displays user details (avatar, name, email) fetched via useGetUserDetails and lets users edit phone number and time zone. Only changed fields are sent to the server using usePatchUserMe; success and error banners are shown via context. The modal shows a loading state, disables closing/actions while the patch is pending, and uses TIME_ZONE_OPTIONS and Oxygen UI components for form controls. --- .../common/header/UserProfileModal.tsx | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 apps/customer-portal/webapp/src/components/common/header/UserProfileModal.tsx diff --git a/apps/customer-portal/webapp/src/components/common/header/UserProfileModal.tsx b/apps/customer-portal/webapp/src/components/common/header/UserProfileModal.tsx new file mode 100644 index 0000000000..e63cbad764 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/common/header/UserProfileModal.tsx @@ -0,0 +1,216 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + type JSX, + useState, + useEffect, + useCallback, + type ChangeEvent, +} from "react"; +import { + Avatar, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import useGetUserDetails from "@api/useGetUserDetails"; +import { usePatchUserMe } from "@api/usePatchUserMe"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; +import { useSuccessBanner } from "@context/success-banner/SuccessBannerContext"; +import { TIME_ZONE_OPTIONS } from "@constants/timeZoneConstants"; + +export interface UserProfileModalProps { + open: boolean; + onClose: () => void; +} + +/** + * Modal displaying user profile from useGetUserDetails (avatar, name, email) + * with editable phone number and time zone. PATCH /users/me with only changed fields. + * + * @param {UserProfileModalProps} props - open, onClose. + * @returns {JSX.Element} The profile modal. + */ +export default function UserProfileModal({ + open, + onClose, +}: UserProfileModalProps): JSX.Element { + const { showError } = useErrorBanner(); + const { showSuccess } = useSuccessBanner(); + const { data: userDetails, isLoading } = useGetUserDetails(); + const patchUserMe = usePatchUserMe(); + + const [phoneNumber, setPhoneNumber] = useState(""); + const [timeZone, setTimeZone] = useState(""); + + useEffect(() => { + if (open && userDetails) { + setPhoneNumber(userDetails.phoneNumber ?? ""); + setTimeZone(userDetails.timeZone ?? ""); + } + }, [open, userDetails]); + + const handlePhoneChange = useCallback( + (e: ChangeEvent) => setPhoneNumber(e.target.value), + [], + ); + const handleTimeZoneChange = useCallback( + (e: { target: { value: unknown } }) => + setTimeZone(String(e.target.value ?? "")), + [], + ); + + const handleSave = useCallback(() => { + if (!userDetails) return; + + const payload: { phoneNumber?: string; timeZone?: string } = {}; + if (String(userDetails.phoneNumber ?? "") !== phoneNumber) { + payload.phoneNumber = phoneNumber; + } + if (String(userDetails.timeZone ?? "") !== timeZone) { + payload.timeZone = timeZone; + } + + if (Object.keys(payload).length === 0) { + onClose(); + return; + } + + patchUserMe.mutate(payload, { + onSuccess: () => { + showSuccess("Profile updated successfully"); + onClose(); + }, + onError: (err) => { + showError(err?.message ?? "Failed to update profile"); + }, + }); + }, [userDetails, phoneNumber, timeZone, patchUserMe, showSuccess, showError, onClose]); + + const handleClose = useCallback(() => { + if (!patchUserMe.isPending) { + onClose(); + } + }, [onClose, patchUserMe.isPending]); + + const name = + userDetails?.firstName || userDetails?.lastName + ? `${userDetails.firstName ?? ""} ${userDetails.lastName ?? ""}`.trim() + : "--"; + const email = userDetails?.email ?? "--"; + + const initials = (() => { + const first = userDetails?.firstName?.charAt(0) ?? ""; + const last = userDetails?.lastName?.charAt(0) ?? ""; + return (first + last).toUpperCase() || "?"; + })(); + + return ( + + + Profile + + + {isLoading || !userDetails ? ( + + + + ) : ( + + + + {!userDetails.avatar ? initials : null} + + {name} + + {email} + + + + + + + Time Zone + + + + )} + + + + + + + ); +} From 100ce36ad552601d8b27c5318ecc7962144fd47d Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:54:23 +0530 Subject: [PATCH 4/7] Remove owner-scoped filtering and user fetch Stop fetching current user and remove client-side owner-scoped case filtering. The useGetUserDetails import and ownerScopedCases logic were removed; cases are now taken directly from the paginated query (renamed allCases -> displayedCases). A createdByMe flag is passed to the project cases query when viewMode === "my" so server-side filtering is applied, and viewMode was added to the query deps and memo deps to keep results in sync. Pagination and other filters remain unchanged. --- .../security/SecurityReportAnalysis.tsx | 46 ++----------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/security/SecurityReportAnalysis.tsx b/apps/customer-portal/webapp/src/components/security/SecurityReportAnalysis.tsx index 153aaf9628..67ec0c2ff1 100644 --- a/apps/customer-portal/webapp/src/components/security/SecurityReportAnalysis.tsx +++ b/apps/customer-portal/webapp/src/components/security/SecurityReportAnalysis.tsx @@ -56,7 +56,6 @@ import { useNavigate, useParams } from "react-router"; import { CaseType } from "@constants/supportConstants"; import useGetProjectCases from "@api/useGetProjectCases"; import useGetProjectFilters from "@api/useGetProjectFilters"; -import useGetUserDetails from "@api/useGetUserDetails"; import type { AllCasesFilterValues, CaseListItem } from "@models/responses"; import SecurityReportAnalysisSkeleton from "@components/security/SecurityReportAnalysisSkeleton"; import TabBar from "@components/common/tab-bar/TabBar"; @@ -87,7 +86,6 @@ const SecurityReportAnalysis = (): JSX.Element => { const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); const [page, setPage] = useState(1); const pageSize = 10; - const { data: currentUser } = useGetUserDetails(); // Fetch filter metadata const { data: filterMetadata } = useGetProjectFilters(projectId || ""); @@ -97,6 +95,7 @@ const SecurityReportAnalysis = (): JSX.Element => { () => ({ filters: { caseTypes: [CaseType.SECURITY_REPORT_ANALYSIS], + createdByMe: viewMode === "my" ? true : undefined, statusIds: filters.statusId ? [Number(filters.statusId)] : undefined, severityId: filters.severityId ? Number(filters.severityId) : undefined, issueId: filters.issueTypes ? Number(filters.issueTypes) : undefined, @@ -108,7 +107,7 @@ const SecurityReportAnalysis = (): JSX.Element => { order: sortOrder, }, }), - [filters, searchTerm, sortOrder], + [filters, searchTerm, sortOrder, viewMode], ); // Fetch security report analysis cases @@ -128,50 +127,11 @@ const SecurityReportAnalysis = (): JSX.Element => { void fetchNextPage(); }, [data, hasNextPage, fetchNextPage]); - const allCases = useMemo( + const displayedCases = useMemo( () => data?.pages.flatMap((page) => page.cases) ?? [], [data], ); - const ownerScopedCases = useMemo(() => { - if (viewMode !== "my") { - return allCases; - } - - const userId = currentUser?.id?.trim(); - const userEmail = currentUser?.email?.trim().toLowerCase(); - - if (!userId && !userEmail) { - return []; - } - - return allCases.filter((caseItem) => { - const caseWithOwner = caseItem as CaseListItem & { - ownerId?: string; - createdBy?: string; - }; - const assignedEngineer = caseItem.assignedEngineer; - const assignedEngineerId = - assignedEngineer && typeof assignedEngineer !== "string" - ? assignedEngineer.id - : undefined; - const assignedEngineerLabel = - assignedEngineer && typeof assignedEngineer !== "string" - ? (assignedEngineer.label ?? assignedEngineer.name ?? "") - : (assignedEngineer ?? ""); - - return ( - (userId != null && caseWithOwner.ownerId === userId) || - (userId != null && assignedEngineerId === userId) || - (userEmail != null && - caseWithOwner.createdBy?.toLowerCase() === userEmail) || - (userEmail != null && assignedEngineerLabel.toLowerCase() === userEmail) - ); - }); - }, [allCases, currentUser?.email, currentUser?.id, viewMode]); - - const displayedCases = ownerScopedCases; - const totalItems = displayedCases.length; // Pagination logic From d80270da3ba89603e2c9fa998e507d70c8ae1be7 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:54:38 +0530 Subject: [PATCH 5/7] Add time zone options constant Introduce TIME_ZONE_OPTIONS in apps/customer-portal/webapp/src/constants/timeZoneConstants.ts to provide a curated list of time zone strings for the user profile edit dropdown. The file includes the project license header and exports the array as a readonly tuple (`as const`) so callers can rely on a fixed set of options (including a "System (Asia/Colombo)" entry and WSO2/Colombo). --- .../webapp/src/constants/timeZoneConstants.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 apps/customer-portal/webapp/src/constants/timeZoneConstants.ts diff --git a/apps/customer-portal/webapp/src/constants/timeZoneConstants.ts b/apps/customer-portal/webapp/src/constants/timeZoneConstants.ts new file mode 100644 index 0000000000..e406d51ad9 --- /dev/null +++ b/apps/customer-portal/webapp/src/constants/timeZoneConstants.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** Time zone options for user profile edit dropdown. */ +export const TIME_ZONE_OPTIONS = [ + "System (Asia/Colombo)", + "Africa/Accra", + "Africa/Addis_Ababa", + "Africa/Algiers", + "Africa/Cairo", + "Africa/Casablanca", + "Africa/Ceuta", + "Africa/Dakar", + "Africa/Dar_es_Salaam", + "Africa/Johannesburg", + "Africa/Kampala", + "Africa/Lome", + "Africa/Nairobi", + "Africa/Windhoek", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Bahia", + "America/Bogota", + "America/Boise", + "America/Chicago", + "America/Denver", + "America/Detroit", + "America/Edmonton", + "America/El_Salvador", + "America/Fortaleza", + "America/Guayaquil", + "America/Halifax", + "America/Indiana/Indianapolis", + "America/Indiana/Marengo", + "America/Maceio", + "America/Menominee", + "America/Mexico_City", + "America/Montevideo", + "America/New_York", + "America/Phoenix", + "America/Rainy_River", + "America/Recife", + "America/Sao_Paulo", + "America/Toronto", + "America/Vancouver", + "Asia/Baku", + "Asia/Colombo", + "Asia/Dili", + "Asia/Dubai", + "Asia/Hong_Kong", + "Asia/Ho_Chi_Minh", + "Asia/Jakarta", + "Asia/Jerusalem", + "Asia/Karachi", + "Asia/Kathmandu", + "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Kuwait", + "Asia/Macau", + "Asia/Manila", + "Asia/Riyadh", + "Asia/Seoul", + "Asia/Singapore", + "Asia/Taipei", + "Asia/Tokyo", + "Asia/Ulaanbaatar", + "Asia/Yangon", + "Asia/Kolkata", + "Atlantic/Cape_Verde", + "Atlantic/Reykjavik", + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Melbourne", + "Australia/Sydney", + "Etc/GMT", + "Etc/GMT-12", + "Etc/GMT-3", + "Etc/GMT-4", + "Europe/Amsterdam", + "Europe/Andorra", + "Europe/Athens", + "Europe/Belgrade", + "Europe/Berlin", + "Europe/Bratislava", + "Europe/Brussels", + "Europe/Bucharest", + "Europe/Budapest", + "Europe/Chisinau", + "Europe/Copenhagen", + "Europe/Dublin", + "Europe/Helsinki", + "Europe/Isle_of_Man", + "Europe/Istanbul", + "Europe/Kaliningrad", + "Europe/Kiev", + "Europe/Lisbon", + "Europe/Ljubljana", + "Europe/London", + "Europe/Luxembourg", + "Europe/Madrid", + "Europe/Malta", + "Europe/Minsk", + "Europe/Monaco", + "Europe/Moscow", + "Europe/Oslo", + "Europe/Paris", + "Europe/Prague", + "Europe/Riga", + "Europe/Rome", + "Europe/Sarajevo", + "Europe/Sofia", + "Europe/Stockholm", + "Europe/Vaduz", + "Europe/Vienna", + "Europe/Vilnius", + "Europe/Warsaw", + "Europe/Zagreb", + "Europe/Zurich", + "GMT", + "Hongkong", + "Pacific/Auckland", + "Pacific/Honolulu", + "PST", + "Canada/Atlantic", + "Canada/Central", + "Canada/Eastern", + "Canada/Mountain", + "Canada/Pacific", + "US/Arizona", + "US/Central", + "US/Eastern", + "US/Hawaii", + "US/Mountain", + "US/Pacific", + "America/Los_Angeles", + "WSO2/Colombo", +] as const; From 872ffc03cf4292b5b98f568de75c4a0f4afe56dd Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:55:03 +0530 Subject: [PATCH 6/7] Add PatchChangeRequest and PatchUserMe interfaces Introduce two request-body interfaces in apps/customer-portal/webapp/src/models/requests.ts: PatchChangeRequestRequest for PATCH /change-requests/:id (plannedStartOn: string) and PatchUserMeRequest for PATCH /users/me (optional phoneNumber and timeZone). These types document the payloads used when updating a change request's planned start and when patching the current user's profile (only changed fields should be included). --- apps/customer-portal/webapp/src/models/requests.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/customer-portal/webapp/src/models/requests.ts b/apps/customer-portal/webapp/src/models/requests.ts index aee3915eae..794607f724 100644 --- a/apps/customer-portal/webapp/src/models/requests.ts +++ b/apps/customer-portal/webapp/src/models/requests.ts @@ -235,3 +235,14 @@ export interface TimeCardSearchRequest { export interface ValidateContactRequest { contactEmail: string; } + +// Request body for PATCH /change-requests/:id (update planned start). +export interface PatchChangeRequestRequest { + plannedStartOn: string; +} + +// Request body for PATCH /users/me (update user profile). +export interface PatchUserMeRequest { + phoneNumber?: string; + timeZone?: string; +} From a643be67b37453bffaee94232326cbb3025755e1 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 6 Mar 2026 09:55:28 +0530 Subject: [PATCH 7/7] Add optional user fields and patch response Add optional phoneNumber and avatar fields to UserDetails (string | null) to carry contact and avatar data. Introduce PatchChangeRequestResponse interface for PATCH /change-requests/:id responses, containing id, updatedBy, and updatedOn to model update metadata. --- apps/customer-portal/webapp/src/models/responses.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/customer-portal/webapp/src/models/responses.ts b/apps/customer-portal/webapp/src/models/responses.ts index 72a4cfd584..0808ad202f 100644 --- a/apps/customer-portal/webapp/src/models/responses.ts +++ b/apps/customer-portal/webapp/src/models/responses.ts @@ -77,6 +77,8 @@ export interface UserDetails { lastName: string; firstName: string; timeZone: string; + phoneNumber?: string | null; + avatar?: string | null; } // Project user (invited/registered) for project users list. @@ -371,6 +373,13 @@ export interface ChangeRequestStats { completed: number; } +// Response from PATCH /change-requests/:id (update planned start). +export interface PatchChangeRequestResponse { + id: string; + updatedBy: string; + updatedOn: string; +} + // Change Request Stats API Response export interface ChangeRequestStatsResponse { totalCount: number;