From c5086f2163bd01b4591fb88e178fc51fd9e716d1 Mon Sep 17 00:00:00 2001 From: Sangay Thinley <59992112+sangayt1997@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:17:47 +0600 Subject: [PATCH 01/15] feat(construct): change the header content according to figma and add multi-orgs content --- src/components/core/index.ts | 2 + .../core/org-switcher/org-switcher.tsx | 69 +++++++++++++++++++ .../core/profile-menu/profile-menu.tsx | 40 +---------- .../core/theme-switcher/theme-switcher.tsx | 18 +++++ src/layout/main-layout/main-layout.tsx | 6 +- 5 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 src/components/core/org-switcher/org-switcher.tsx create mode 100644 src/components/core/theme-switcher/theme-switcher.tsx diff --git a/src/components/core/index.ts b/src/components/core/index.ts index e9b07528..96b1251a 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -32,3 +32,5 @@ export { Notification } from './notification/component/notification/notification export { NotificationItem } from './notification/component/notification-item/notification-item'; export * from './notification/hooks/use-notification'; export { ExtensionBanner } from './extension-banner/extension-banner'; +export { ThemeSwitcher } from './theme-switcher/theme-switcher'; +export { OrgSwitcher } from './org-switcher/org-switcher'; diff --git a/src/components/core/org-switcher/org-switcher.tsx b/src/components/core/org-switcher/org-switcher.tsx new file mode 100644 index 00000000..c6e20401 --- /dev/null +++ b/src/components/core/org-switcher/org-switcher.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Building2, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui-kit/dropdown-menu'; +import { Skeleton } from '@/components/ui-kit/skeleton'; +import { useGetAccount } from '@/modules/profile/hooks/use-account'; +import { getUserRoles } from '@/hooks/use-user-roles'; + +export const OrgSwitcher = () => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { t } = useTranslation(); + + const { data, isLoading } = useGetAccount(); + + const userRoles = getUserRoles(data ?? null); + const translatedRoles = userRoles + .map((role: string) => { + const roleKey = role.toUpperCase(); + return t(roleKey); + }) + .join(', '); + + return ( + + +
+
+ {isLoading ? ( + + ) : ( + + )} +
+
+ {isLoading ? ( + + ) : ( +

Organization 1

+ )} +

{translatedRoles}

+
+ {isDropdownOpen ? ( + + ) : ( + + )} +
+
+ + Organization 1 + Organization 2 + Organization 3 + + {t('CREATE_NEW')} + +
+ ); +}; diff --git a/src/components/core/profile-menu/profile-menu.tsx b/src/components/core/profile-menu/profile-menu.tsx index 5c1ef391..400112d0 100644 --- a/src/components/core/profile-menu/profile-menu.tsx +++ b/src/components/core/profile-menu/profile-menu.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronUp, Moon, Sun } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DropdownMenu, @@ -13,9 +12,7 @@ import { useSignoutMutation } from '@/modules/auth/hooks/use-auth'; import { useAuthStore } from '@/state/store/auth'; import DummyProfile from '@/assets/images/dummy_profile.png'; import { Skeleton } from '@/components/ui-kit/skeleton'; -import { useTheme } from '@/styles/theme/theme-provider'; import { useGetAccount } from '@/modules/profile/hooks/use-account'; -import { getUserRoles } from '@/hooks/use-user-roles'; /** * ProfileMenu Component @@ -24,15 +21,12 @@ import { getUserRoles } from '@/hooks/use-user-roles'; * navigation and account management options. * * Features: - * - Displays user profile image and name + * - Displays user profile image * - Shows loading states with skeleton placeholders * - Provides navigation to profile page - * - Includes theme toggling functionality * - Handles user logout with authentication state management - * - Responsive design with different spacing for mobile and desktop * * Dependencies: - * - Requires useTheme hook for theme management * - Requires useAuthStore for authentication state management * - Requires useSignoutMutation for API logout functionality * - Requires useGetAccount for fetching user account data @@ -46,7 +40,6 @@ import { getUserRoles } from '@/hooks/use-user-roles'; export const ProfileMenu = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const { theme, setTheme } = useTheme(); const { t } = useTranslation(); const { logout } = useAuthStore(); @@ -68,14 +61,6 @@ export const ProfileMenu = () => { const fullName = `${data?.firstName ?? ''} ${data?.lastName ?? ''}`.trim() ?? ' '; - const userRoles = getUserRoles(data ?? null); - const translatedRoles = userRoles - .map((role: string) => { - const roleKey = role.toUpperCase(); - return t(roleKey); - }) - .join(', '); - useEffect(() => { if (data) { localStorage.setItem( @@ -108,19 +93,6 @@ export const ProfileMenu = () => { /> )} -
- {isLoading ? ( - - ) : ( -

{fullName}

- )} -

{translatedRoles}

-
- {isDropdownOpen ? ( - - ) : ( - - )} { {t('ABOUT')} {t('PRIVACY_POLICY')} - setTheme(theme === 'dark' ? 'light' : 'dark')} - > - {t('THEME')} - - - {t('LOG_OUT')} diff --git a/src/components/core/theme-switcher/theme-switcher.tsx b/src/components/core/theme-switcher/theme-switcher.tsx new file mode 100644 index 00000000..0430ebce --- /dev/null +++ b/src/components/core/theme-switcher/theme-switcher.tsx @@ -0,0 +1,18 @@ +import { useTheme } from '@/styles/theme/theme-provider'; +import { Button } from '@/components/ui-kit/button'; +import { Moon, Sun } from 'lucide-react'; + +export const ThemeSwitcher = () => { + const { theme, setTheme } = useTheme(); + + return ( + + ); +}; diff --git a/src/layout/main-layout/main-layout.tsx b/src/layout/main-layout/main-layout.tsx index ee7e5f59..87aef64b 100644 --- a/src/layout/main-layout/main-layout.tsx +++ b/src/layout/main-layout/main-layout.tsx @@ -9,6 +9,8 @@ import { AppSidebar, Notification, useGetNotifications, + ThemeSwitcher, + OrgSwitcher, } from '@/components/core'; type NotificationsData = { @@ -58,7 +60,8 @@ export const MainLayout = () => {
-
+
+ {/* TODO: Need later when the docs are ready and do binding to redirect to docs page*/} {/*
Date: Fri, 23 Jan 2026 15:29:42 +0600 Subject: [PATCH 02/15] feat(construct): implementing the getOrganization api in the org-switcher component --- .../core/org-switcher/org-switcher.tsx | 43 ++++++++++++++++--- .../core/theme-switcher/theme-switcher.tsx | 2 +- src/lib/api/hooks/use-multi-orgs.ts | 10 +++++ src/lib/api/services/multi-orgs.service.ts | 31 +++++++++++++ src/lib/api/types/multi-orgs.types.ts | 39 +++++++++++++++++ 5 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 src/lib/api/hooks/use-multi-orgs.ts create mode 100644 src/lib/api/services/multi-orgs.service.ts create mode 100644 src/lib/api/types/multi-orgs.types.ts diff --git a/src/components/core/org-switcher/org-switcher.tsx b/src/components/core/org-switcher/org-switcher.tsx index c6e20401..58dcf53a 100644 --- a/src/components/core/org-switcher/org-switcher.tsx +++ b/src/components/core/org-switcher/org-switcher.tsx @@ -11,12 +11,28 @@ import { import { Skeleton } from '@/components/ui-kit/skeleton'; import { useGetAccount } from '@/modules/profile/hooks/use-account'; import { getUserRoles } from '@/hooks/use-user-roles'; +import { useGetMultiOrgs } from '@/lib/api/hooks/use-multi-orgs'; + +const projectKey = import.meta.env.VITE_X_BLOCKS_KEY || ''; export const OrgSwitcher = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [selectedOrgId, setSelectedOrgId] = useState(null); const { t } = useTranslation(); const { data, isLoading } = useGetAccount(); + const { data: orgsData, isLoading: isLoadingOrgs } = useGetMultiOrgs({ + ProjectKey: projectKey, + Page: 0, + PageSize: 10, + }); + + const organizations = orgsData?.organizations ?? []; + const enabledOrganizations = organizations.filter((org) => org.isEnable); + + const selectedOrg = selectedOrgId + ? enabledOrganizations.find((org) => org.itemId === selectedOrgId) + : enabledOrganizations[0]; const userRoles = getUserRoles(data ?? null); const translatedRoles = userRoles @@ -26,22 +42,31 @@ export const OrgSwitcher = () => { }) .join(', '); + const handleOrgSelect = (orgId: string) => { + setSelectedOrgId(orgId); + setIsDropdownOpen(false); + }; + + const isComponentLoading = isLoading || isLoadingOrgs; + return (
- {isLoading ? ( + {isComponentLoading ? ( ) : ( )}
- {isLoading ? ( + {isComponentLoading ? ( ) : ( -

Organization 1

+

+ {selectedOrg?.name || 'Organization 1'} +

)}

{translatedRoles}

@@ -58,9 +83,15 @@ export const OrgSwitcher = () => { side="top" sideOffset={10} > - Organization 1 - Organization 2 - Organization 3 + {enabledOrganizations.length > 0 ? ( + enabledOrganizations.map((org) => ( + handleOrgSelect(org.itemId)}> + {org.name} + + )) + ) : ( + Organization 1 + )} {t('CREATE_NEW')} diff --git a/src/components/core/theme-switcher/theme-switcher.tsx b/src/components/core/theme-switcher/theme-switcher.tsx index 0430ebce..c824c7a4 100644 --- a/src/components/core/theme-switcher/theme-switcher.tsx +++ b/src/components/core/theme-switcher/theme-switcher.tsx @@ -1,6 +1,6 @@ +import { Moon, Sun } from 'lucide-react'; import { useTheme } from '@/styles/theme/theme-provider'; import { Button } from '@/components/ui-kit/button'; -import { Moon, Sun } from 'lucide-react'; export const ThemeSwitcher = () => { const { theme, setTheme } = useTheme(); diff --git a/src/lib/api/hooks/use-multi-orgs.ts b/src/lib/api/hooks/use-multi-orgs.ts new file mode 100644 index 00000000..e626e9cc --- /dev/null +++ b/src/lib/api/hooks/use-multi-orgs.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { getMultiOrgs } from '../services/multi-orgs.service'; +import type { GetOrganizationsParams, GetOrganizationsResponse } from '../types/multi-orgs.types'; + +export const useGetMultiOrgs = (params?: GetOrganizationsParams) => { + return useQuery({ + queryKey: ['getMultiOrgs', params], + queryFn: () => getMultiOrgs(params), + }); +}; diff --git a/src/lib/api/services/multi-orgs.service.ts b/src/lib/api/services/multi-orgs.service.ts new file mode 100644 index 00000000..a732a8c0 --- /dev/null +++ b/src/lib/api/services/multi-orgs.service.ts @@ -0,0 +1,31 @@ +import { clients } from '@/lib/https'; +import type { GetOrganizationsParams, GetOrganizationsResponse } from '../types/multi-orgs.types'; + +const buildQueryString = (params?: GetOrganizationsParams): string => { + if (!params) return ''; + + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + value.forEach((item) => searchParams.append(key, item)); + } else { + searchParams.append(key, String(value)); + } + } + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +}; + +export const getMultiOrgs = async ( + params?: GetOrganizationsParams +): Promise => { + const queryString = buildQueryString(params); + const res = await clients.get( + `/idp/v1/Iam/GetOrganizations${queryString}` + ); + return res; +}; diff --git a/src/lib/api/types/multi-orgs.types.ts b/src/lib/api/types/multi-orgs.types.ts new file mode 100644 index 00000000..cbcfac01 --- /dev/null +++ b/src/lib/api/types/multi-orgs.types.ts @@ -0,0 +1,39 @@ +export interface GetOrganizationsParams { + ProjectKey?: string; + Page?: number; + PageSize?: number; + 'Sort.Property'?: string; + 'Sort.IsDescending'?: boolean; + 'Filter.Name'?: string; + 'Filter.IsEnable'?: boolean; + 'Filter.ItemId'?: string; + 'Filter.CreatedDate'?: string; + 'Filter.LastUpdatedDate'?: string; + 'Filter.CreatedBy'?: string; + 'Filter.Language'?: string; + 'Filter.LastUpdatedBy'?: string; + 'Filter.OrganizationIds'?: string[]; + 'Filter.Tags'?: string[]; +} + +export interface Organization { + itemId: string; + createdDate: string; + lastUpdatedDate: string; + createdBy: string; + language: string; + lastUpdatedBy: string; + organizationIds: string[]; + tags: string[]; + name: string; + isEnable: boolean; +} + +export interface GetOrganizationsResponse { + errors?: { + [key: string]: string; + }; + isSuccess: boolean; + organizations: Organization[]; + totalCount: number; +} From 1a166c970419c409fa05d2d591964bda22fcf58b Mon Sep 17 00:00:00 2001 From: Sangay Thinley <59992112+sangayt1997@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:05:51 +0600 Subject: [PATCH 03/15] feat(construct): adding the organization switch trigger the grantType changed in Token endpoint --- src/components/core/divider/divider.tsx | 4 +- .../core/org-switcher/org-switcher.tsx | 57 +++++++++++++++++-- .../core/theme-switcher/theme-switcher.tsx | 2 +- src/components/ui-kit/button.tsx | 2 +- src/components/ui-kit/tabs.tsx | 2 +- src/modules/auth/services/auth.service.ts | 25 ++++++++ 6 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/components/core/divider/divider.tsx b/src/components/core/divider/divider.tsx index 64e7fb4f..14bb0be3 100644 --- a/src/components/core/divider/divider.tsx +++ b/src/components/core/divider/divider.tsx @@ -6,11 +6,11 @@ export const Divider = ({ text }: DividerProps) => { return (
-
+
{text}
-
+
); diff --git a/src/components/core/org-switcher/org-switcher.tsx b/src/components/core/org-switcher/org-switcher.tsx index 58dcf53a..1a5a5f7b 100644 --- a/src/components/core/org-switcher/org-switcher.tsx +++ b/src/components/core/org-switcher/org-switcher.tsx @@ -12,13 +12,20 @@ import { Skeleton } from '@/components/ui-kit/skeleton'; import { useGetAccount } from '@/modules/profile/hooks/use-account'; import { getUserRoles } from '@/hooks/use-user-roles'; import { useGetMultiOrgs } from '@/lib/api/hooks/use-multi-orgs'; +import { switchOrganization } from '@/modules/auth/services/auth.service'; +import { useAuthStore } from '@/state/store/auth'; +import { useToast } from '@/hooks/use-toast'; +import { HttpError } from '@/lib/https'; const projectKey = import.meta.env.VITE_X_BLOCKS_KEY || ''; export const OrgSwitcher = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [selectedOrgId, setSelectedOrgId] = useState(null); + const [isSwitching, setIsSwitching] = useState(false); const { t } = useTranslation(); + const { setTokens } = useAuthStore(); + const { toast } = useToast(); const { data, isLoading } = useGetAccount(); const { data: orgsData, isLoading: isLoadingOrgs } = useGetMultiOrgs({ @@ -42,12 +49,54 @@ export const OrgSwitcher = () => { }) .join(', '); - const handleOrgSelect = (orgId: string) => { - setSelectedOrgId(orgId); - setIsDropdownOpen(false); + const handleOrgSelect = async (orgId: string) => { + if (isSwitching || orgId === selectedOrgId) return; + + try { + setIsSwitching(true); + setIsDropdownOpen(false); + + const response = await switchOrganization(orgId); + + setTokens({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + }); + setSelectedOrgId(orgId); + + window.location.reload(); + } catch (error) { + console.error('Failed to switch organization:', error); + setIsSwitching(false); + + let errorTitle = t('FAILED_TO_SWITCH_ORGANIZATION'); + let errorDescription = t('SOMETHING_WENT_WRONG'); + + if (error instanceof HttpError) { + const errorData = error.error; + + if (errorData?.error === 'user_inactive_or_not_verified') { + errorTitle = t('ACCESS_DENIED'); + errorDescription = + typeof errorData?.error_description === 'string' + ? errorData.error_description + : t('USER_NOT_EXIST_IN_ORGANIZATION'); + } else if (typeof errorData?.error_description === 'string') { + errorDescription = errorData.error_description; + } else if (typeof errorData?.error === 'string') { + errorDescription = errorData.error; + } + } + + toast({ + variant: 'destructive', + title: errorTitle, + description: errorDescription, + }); + } }; - const isComponentLoading = isLoading || isLoadingOrgs; + const isComponentLoading = isLoading || isLoadingOrgs || isSwitching; return ( diff --git a/src/components/core/theme-switcher/theme-switcher.tsx b/src/components/core/theme-switcher/theme-switcher.tsx index c824c7a4..f5fa0fe7 100644 --- a/src/components/core/theme-switcher/theme-switcher.tsx +++ b/src/components/core/theme-switcher/theme-switcher.tsx @@ -9,7 +9,7 @@ export const ThemeSwitcher = () => {
@@ -162,7 +162,7 @@ export const OrgSwitcher = () => { )) ) : ( - Organization 1 + No orgs found )} {t('CREATE_NEW')} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 40dea5a3..2f55ad1b 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -107,6 +107,9 @@ const getApiUrl = (path: string) => { return `${baseUrl}${cleanPath}`; }; +export const savedOrgId = + typeof window !== 'undefined' ? window.localStorage.getItem('selected-org-id') : null; + export const signin = async < T extends 'password' | 'social' | 'mfa_code' | 'authorization_code' = 'password', >( @@ -121,6 +124,10 @@ export const signin = async < passwordFormData.append('grant_type', 'password'); passwordFormData.append('username', payload.username); passwordFormData.append('password', payload.password); + + if (savedOrgId) { + passwordFormData.append('org_id', savedOrgId); + } } const response = await fetch(url, { method: 'POST', @@ -144,6 +151,10 @@ export const signin = async < signinBySSOData.append('code', payload.code); signinBySSOData.append('state', payload.state); + if (savedOrgId) { + signinBySSOData.append('org_id', savedOrgId); + } + const response = await fetch(url, { method: 'POST', body: signinBySSOData, @@ -164,6 +175,10 @@ export const signin = async < const signinBySSOData = new URLSearchParams(); signinBySSOData.append('grant_type', 'authorization_code'); signinBySSOData.append('code', payload.code); + + if (savedOrgId) { + signinBySSOData.append('org_id', savedOrgId); + } const response = await fetch(url, { method: 'POST', body: signinBySSOData, @@ -297,6 +312,11 @@ export const signinByEmail = (payload: SigninEmailPayload): Promise Date: Mon, 26 Jan 2026 14:36:26 +0600 Subject: [PATCH 08/15] fix(construct): fixed the switch orgs affecting the permission guards --- .../core/org-switcher/org-switcher.tsx | 41 ++++++++---------- .../core/profile-menu/profile-menu.tsx | 43 ++++++++++++------- src/constant/roles-permissions.ts | 14 ------ src/lib/utils/decode-jwt-utils.ts | 29 +++++++++++++ src/state/store/auth/use-is-protected.ts | 20 +++++++-- 5 files changed, 91 insertions(+), 56 deletions(-) delete mode 100644 src/constant/roles-permissions.ts create mode 100644 src/lib/utils/decode-jwt-utils.ts diff --git a/src/components/core/org-switcher/org-switcher.tsx b/src/components/core/org-switcher/org-switcher.tsx index a22ba81f..7ba2edad 100644 --- a/src/components/core/org-switcher/org-switcher.tsx +++ b/src/components/core/org-switcher/org-switcher.tsx @@ -10,32 +10,15 @@ import { } from '@/components/ui-kit/dropdown-menu'; import { Skeleton } from '@/components/ui-kit/skeleton'; import { useGetAccount } from '@/modules/profile/hooks/use-account'; -import { getUserRoles } from '@/hooks/use-user-roles'; import { useGetMultiOrgs } from '@/lib/api/hooks/use-multi-orgs'; import { switchOrganization } from '@/modules/auth/services/auth.service'; import { useAuthStore } from '@/state/store/auth'; import { useToast } from '@/hooks/use-toast'; import { HttpError } from '@/lib/https'; +import { decodeJWT } from '@/lib/utils/decode-jwt-utils'; const projectKey = import.meta.env.VITE_X_BLOCKS_KEY || ''; -const decodeJWT = (token: string): { org_id?: string } | null => { - try { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) - .join('') - ); - return JSON.parse(jsonPayload); - } catch (error) { - console.error('Failed to decode JWT:', error); - return null; - } -}; - export const OrgSwitcher = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isSwitching, setIsSwitching] = useState(false); @@ -63,8 +46,13 @@ export const OrgSwitcher = () => { ? enabledOrganizations.find((org) => org.itemId === currentOrgId) : enabledOrganizations[0]; - const userRoles = getUserRoles(data ?? null); - const translatedRoles = userRoles + const currentOrgRoles = useMemo(() => { + if (!data?.memberships?.length || !currentOrgId) return []; + const membership = data.memberships.find((m) => m.organizationId === currentOrgId); + return membership?.roles ?? []; + }, [data, currentOrgId]); + + const translatedRoles = currentOrgRoles .map((role: string) => { const roleKey = role.toUpperCase(); return t(roleKey); @@ -136,11 +124,18 @@ export const OrgSwitcher = () => {
{isComponentLoading ? ( - + <> + + + ) : ( -

{selectedOrg?.name ?? '_'}

+ <> +

+ {selectedOrg?.name ?? '_'} +

+

{translatedRoles}

+ )} -

{translatedRoles}

{isDropdownOpen ? ( diff --git a/src/components/core/profile-menu/profile-menu.tsx b/src/components/core/profile-menu/profile-menu.tsx index 400112d0..4e0bae32 100644 --- a/src/components/core/profile-menu/profile-menu.tsx +++ b/src/components/core/profile-menu/profile-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -40,6 +40,8 @@ import { useGetAccount } from '@/modules/profile/hooks/use-account'; export const ProfileMenu = () => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isImageLoaded, setIsImageLoaded] = useState(false); + const imgRef = useRef(null); const { t } = useTranslation(); const { logout } = useAuthStore(); @@ -60,6 +62,8 @@ export const ProfileMenu = () => { }; const fullName = `${data?.firstName ?? ''} ${data?.lastName ?? ''}`.trim() ?? ' '; + const profileImageUrl = + data?.profileImageUrl !== '' ? (data?.profileImageUrl ?? DummyProfile) : DummyProfile; useEffect(() => { if (data) { @@ -73,25 +77,34 @@ export const ProfileMenu = () => { } }, [data, fullName]); + useEffect(() => { + setIsImageLoaded(false); + }, [profileImageUrl]); + + useEffect(() => { + const img = imgRef.current; + if (img && img.complete && img.naturalHeight !== 0) { + setIsImageLoaded(true); + } + }, [profileImageUrl, isLoading]); + + const showSkeleton = isLoading || !isImageLoaded; + return (
- {isLoading ? ( - - ) : ( - profile - )} + {showSkeleton && } + profile setIsImageLoaded(true)} + onError={() => setIsImageLoaded(true)} + />
diff --git a/src/constant/roles-permissions.ts b/src/constant/roles-permissions.ts deleted file mode 100644 index afa28b39..00000000 --- a/src/constant/roles-permissions.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const MENU_PERMISSIONS = { - // Role-based access - ADMIN_ONLY: 'admin', - - // Feature-based permissions - INVOICE_READ: 'invoice.read', - INVOICE_WRITE: 'invoice.write', - - IAM_READ: 'iam.read', - IAM_WRITE: 'iam.write', - - ACTIVITY_LOG_READ: 'activity.read', - ACTIVITY_LOG_WRITE: 'activity.write', -} as const; diff --git a/src/lib/utils/decode-jwt-utils.ts b/src/lib/utils/decode-jwt-utils.ts new file mode 100644 index 00000000..fe5a9086 --- /dev/null +++ b/src/lib/utils/decode-jwt-utils.ts @@ -0,0 +1,29 @@ +/** + * Decodes a JWT access token to extract the `org_id` and other payload claims. + * + * Used by: + * - `OrgSwitcher` - to display the current organization name + * - `useIsProtected` - to enforce organization-specific role-based access control + * + * When users switch organizations, the backend issues a new JWT with the selected `org_id`. + * This function extracts that `org_id` to determine which organization's roles should be + * used for feature guards and protected routes. + * + * @param token - JWT access token string + * @returns Decoded payload with `org_id`, or `null` if decoding fails + */ +export const decodeJWT = (token: string): { org_id?: string } | null => { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + return JSON.parse(jsonPayload); + } catch (error) { + return null; + } +}; diff --git a/src/state/store/auth/use-is-protected.ts b/src/state/store/auth/use-is-protected.ts index 9336dc1d..c8a19db4 100644 --- a/src/state/store/auth/use-is-protected.ts +++ b/src/state/store/auth/use-is-protected.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useAuthStore } from '.'; -import { getUserRoles } from '@/hooks/use-user-roles'; +import { decodeJWT } from '@/lib/utils/decode-jwt-utils'; type UseIsProtectedOptions = { roles?: string[]; @@ -8,6 +8,18 @@ type UseIsProtectedOptions = { opt?: 'all' | 'any'; }; +const getCurrentOrgRoles = (user: any, accessToken: string | null): string[] => { + if (!user?.memberships?.length || !accessToken) return []; + + const decoded = decodeJWT(accessToken); + const currentOrgId = decoded?.org_id; + + if (!currentOrgId) return []; + + const membership = user.memberships.find((m: any) => m.organizationId === currentOrgId); + return membership?.roles ?? []; +}; + const checkAllRoles = (userRoles: string[] | undefined, requiredRoles: string[]): boolean => { if (requiredRoles.length === 0) return true; return requiredRoles.every((role) => userRoles?.includes(role)); @@ -39,13 +51,13 @@ export const useIsProtected = ({ permissions = [], opt = 'any', }: UseIsProtectedOptions = {}) => { - const { user, isAuthenticated } = useAuthStore(); + const { user, isAuthenticated, accessToken } = useAuthStore(); const isProtected = useMemo(() => { if (!isAuthenticated || !user) return false; if (roles.length === 0 && permissions.length === 0) return false; - const userRoles = getUserRoles(user); + const userRoles = getCurrentOrgRoles(user, accessToken); if (opt === 'all') { const hasAllRoles = checkAllRoles(userRoles, roles); @@ -56,7 +68,7 @@ export const useIsProtected = ({ const hasAnyRole = checkAnyRole(userRoles, roles); const hasAnyPermission = checkAnyPermission(user.permissions, permissions); return hasAnyRole || hasAnyPermission; - }, [isAuthenticated, user, roles, permissions, opt]); + }, [isAuthenticated, user, accessToken, roles, permissions, opt]); return { isProtected, From 3d429f93ceff2f765c15d8d7759edf879e289e56 Mon Sep 17 00:00:00 2001 From: Sangay Thinley <59992112+sangayt1997@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:13:30 +0600 Subject: [PATCH 09/15] fix(construct): fixed the darkmode transition issue due to transition utilities class --- package-lock.json | 8 ++++ package.json | 1 + .../notification-item/notification-item.tsx | 2 +- .../core/phone-input/phone-input.tsx | 2 +- src/components/ui-kit/badge.tsx | 2 +- src/components/ui-kit/breadcrumb.tsx | 8 +--- src/components/ui-kit/input.tsx | 2 +- src/components/ui-kit/scroll-area.tsx | 2 +- src/components/ui-kit/slider.tsx | 2 +- src/components/ui-kit/table.tsx | 5 +-- src/components/ui-kit/textarea.tsx | 2 +- src/components/ui-kit/toast.tsx | 2 +- src/layout/auth-layout/auth-layout.tsx | 2 +- .../agenda-content/agenda-content.tsx | 2 +- .../chat-contact-item/chat-contact-item.tsx | 4 +- .../components/email-list/email-list.tsx | 4 +- .../file-dropdown-menu/file-dropdown-menu.tsx | 4 +- .../file-manager-add-new-dropdown.tsx | 2 +- .../components/file-preview/file-preview.tsx | 40 +++++-------------- .../modals/shared-user/shared-user.tsx | 10 ++--- .../task-details-view/assignee-selector.tsx | 2 +- .../task-details-view/attachment-section.tsx | 4 +- .../editable-description.tsx | 2 +- 23 files changed, 48 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d8dcca8..8461bfd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", + "@types/lodash": "^4.17.23", "@types/node": "^20.19.19", "@types/papaparse": "^5.3.15", "@types/react": "^19.0.0", @@ -5551,6 +5552,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", diff --git a/package.json b/package.json index bdfbc12b..c0add04d 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", + "@types/lodash": "^4.17.23", "@types/node": "^20.19.19", "@types/papaparse": "^5.3.15", "@types/react": "^19.0.0", diff --git a/src/components/core/notification/component/notification-item/notification-item.tsx b/src/components/core/notification/component/notification-item/notification-item.tsx index f2393ca9..bc664d76 100644 --- a/src/components/core/notification/component/notification-item/notification-item.tsx +++ b/src/components/core/notification/component/notification-item/notification-item.tsx @@ -50,7 +50,7 @@ export const NotificationItem = ({ notification }: Readonly -
+
diff --git a/src/components/core/phone-input/phone-input.tsx b/src/components/core/phone-input/phone-input.tsx index e18d4549..e2a2f998 100644 --- a/src/components/core/phone-input/phone-input.tsx +++ b/src/components/core/phone-input/phone-input.tsx @@ -81,7 +81,7 @@ const UIPhoneInput = forwardRef( placeholder={placeholder} defaultCountry={defaultCountry} className={cn( - 'flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', + 'flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className )} countryCallingCodeEditable={countryCallingCodeEditable} diff --git a/src/components/ui-kit/badge.tsx b/src/components/ui-kit/badge.tsx index fb5a0134..adf60e7c 100644 --- a/src/components/ui-kit/badge.tsx +++ b/src/components/ui-kit/badge.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/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', + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { diff --git a/src/components/ui-kit/breadcrumb.tsx b/src/components/ui-kit/breadcrumb.tsx index 05354581..1bad714a 100644 --- a/src/components/ui-kit/breadcrumb.tsx +++ b/src/components/ui-kit/breadcrumb.tsx @@ -40,13 +40,7 @@ const BreadcrumbLink = React.forwardRef< >(({ asChild, className, ...props }, ref) => { const Comp = asChild ? Slot : 'a'; - return ( - - ); + return ; }); BreadcrumbLink.displayName = 'BreadcrumbLink'; diff --git a/src/components/ui-kit/input.tsx b/src/components/ui-kit/input.tsx index 9462cec3..3e4960ed 100644 --- a/src/components/ui-kit/input.tsx +++ b/src/components/ui-kit/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( ))} diff --git a/src/components/ui-kit/table.tsx b/src/components/ui-kit/table.tsx index 519e4d30..babe23f5 100644 --- a/src/components/ui-kit/table.tsx +++ b/src/components/ui-kit/table.tsx @@ -47,10 +47,7 @@ const TableRow = React.forwardRef ( ) diff --git a/src/components/ui-kit/textarea.tsx b/src/components/ui-kit/textarea.tsx index bd0aa5f6..2ca1e760 100644 --- a/src/components/ui-kit/textarea.tsx +++ b/src/components/ui-kit/textarea.tsx @@ -11,7 +11,7 @@ const Textarea = React.forwardRef( return (