diff --git a/cypress/e2e/banners.cy.ts b/cypress/e2e/banners.cy.ts new file mode 100644 index 000000000..1b59a6119 --- /dev/null +++ b/cypress/e2e/banners.cy.ts @@ -0,0 +1,31 @@ +import { + MEMBER_VALIDATION_BANNER_CLOSE_BUTTON_ID, + MEMBER_VALIDATION_BANNER_ID, +} from '@/config/selectors'; + +import { + LEGACY_NOT_VALIDATED_MEMBER, + NOT_VALIDATED_MEMBER, + VALIDATED_MEMBER, +} from '../fixtures/members'; + +describe('Member validation banner', () => { + it('Shows banner when member is not validated', () => { + cy.setUpApi({ currentMember: NOT_VALIDATED_MEMBER }); + cy.visit('/'); + cy.get(`#${MEMBER_VALIDATION_BANNER_ID}`).should('be.visible'); + cy.get(`#${MEMBER_VALIDATION_BANNER_CLOSE_BUTTON_ID}`).click(); + }); + + it('Does not show banner when member is validated', () => { + cy.setUpApi({ currentMember: VALIDATED_MEMBER }); + cy.visit('/'); + cy.get(`#${MEMBER_VALIDATION_BANNER_ID}`).should('not.exist'); + }); + + it('Does not show banner when member is legacy', () => { + cy.setUpApi({ currentMember: LEGACY_NOT_VALIDATED_MEMBER }); + cy.visit('/'); + cy.get(`#${MEMBER_VALIDATION_BANNER_ID}`).should('not.exist'); + }); +}); diff --git a/cypress/fixtures/members.ts b/cypress/fixtures/members.ts index 3ce747b76..ce31db532 100644 --- a/cypress/fixtures/members.ts +++ b/cypress/fixtures/members.ts @@ -17,6 +17,7 @@ export const MEMBERS: Record = { lang: 'fr', emailFreq: 'never', }, + isValidated: true, }), BOB: { ...MemberFactory({ @@ -80,10 +81,19 @@ export const MEMBERS: Record = { email: 'garry@email.com', createdAt: '2021-04-13 14:56:34.749946', updatedAt: '2021-04-13 14:56:34.749946', + isValidated: false, }), }; export const CURRENT_USER = MEMBERS.ANNA; +export const NOT_VALIDATED_MEMBER = MEMBERS.GARRY; +export const VALIDATED_MEMBER = MEMBERS.ANNA; +export const LEGACY_NOT_VALIDATED_MEMBER = { + ...NOT_VALIDATED_MEMBER, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + isValidated: undefined, +}; export const MOCK_SESSIONS = [ { id: MEMBERS.BOB.id, token: 'bob-token', createdAt: Date.now() }, diff --git a/src/components/App.tsx b/src/components/App.tsx index 3bf138549..17ee47c72 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -19,7 +19,6 @@ import { buildItemPath, } from '../config/paths'; import { hooks } from '../config/queryClient'; -import { useCurrentUserContext } from './context/CurrentUserContext'; import Main from './main/Main'; import Redirect from './main/Redirect'; import BookmarkedItemsScreen from './pages/BookmarkedItemsScreen'; @@ -34,11 +33,11 @@ import ItemSettingsPage from './pages/item/ItemSettingsPage'; import ItemSharingPage from './pages/item/ItemSharingPage'; import LibrarySettingsPage from './pages/item/LibrarySettingsPage'; -const { useItemFeedbackUpdates } = hooks; +const { useItemFeedbackUpdates, useCurrentMember } = hooks; const App = (): JSX.Element => { const { pathname } = useLocation(); - const { data: currentMember, isLoading } = useCurrentUserContext(); + const { data: currentMember, isLoading } = useCurrentMember(); // registers the item updates through websockets useItemFeedbackUpdates?.(currentMember?.id); diff --git a/src/components/alerts/MemberValidationBanner.tsx b/src/components/alerts/MemberValidationBanner.tsx new file mode 100644 index 000000000..659fbc8d9 --- /dev/null +++ b/src/components/alerts/MemberValidationBanner.tsx @@ -0,0 +1,76 @@ +import { Link } from 'react-router-dom'; + +import { + Alert, + AlertTitle, + IconButton, + Link as MUILink, + Stack, +} from '@mui/material'; + +import { XIcon } from 'lucide-react'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; +import { + MEMBER_VALIDATION_BANNER_CLOSE_BUTTON_ID, + MEMBER_VALIDATION_BANNER_ID, +} from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +import useModalStatus from '../hooks/useModalStatus'; + +const DOCUMENTATION_ORIGIN = 'https://graasp.github.io/docs'; +const MEMBER_VALIDATION_DOCUMENTATION_LINK = '/user/account/validation'; +const buildLocalizedDocumentationOrigin = (lang: string = 'en') => { + switch (lang) { + case 'en': + return DOCUMENTATION_ORIGIN; + default: + return `${DOCUMENTATION_ORIGIN}/${lang}`; + } +}; +// eslint-disable-next-line arrow-body-style +const buildLocalizedDocumentationLink = (lang: string): string => { + // english does not use a path prefix + return `${buildLocalizedDocumentationOrigin(lang)}${MEMBER_VALIDATION_DOCUMENTATION_LINK}`; +}; + +const MemberValidationBanner = (): JSX.Element | false => { + const { isOpen, closeModal } = useModalStatus({ + isInitiallyOpen: true, + }); + const { t, i18n } = useBuilderTranslation(); + const { data: member } = hooks.useCurrentMember(); + + // banner should not be shown when the member does not have the property + if (isOpen && member && 'isValidated' in member && !member.isValidated) { + return ( + + + + } + > + {t(BUILDER.MEMBER_VALIDATION_TITLE)} + + {t(BUILDER.MEMBER_VALIDATION_DESCRIPTION)} + + {t(BUILDER.MEMBER_VALIDATION_LINK_TEXT)} + + + + ); + } + return false; +}; +export default MemberValidationBanner; diff --git a/src/components/common/BookmarkButton.tsx b/src/components/common/BookmarkButton.tsx index 18b7887d0..1f7e70981 100644 --- a/src/components/common/BookmarkButton.tsx +++ b/src/components/common/BookmarkButton.tsx @@ -9,7 +9,6 @@ import { import { useBuilderTranslation } from '../../config/i18n'; import { hooks, mutations } from '../../config/queryClient'; import { BUILDER } from '../../langs/constants'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; type Props = { item: DiscriminatedItem; @@ -29,7 +28,7 @@ const BookmarkButton = ({ type, onClick, }: Props): JSX.Element | null => { - const { data: member } = useCurrentUserContext(); + const { data: member } = hooks.useCurrentMember(); const { data: bookmarks } = hooks.useBookmarkedItems(); const { t: translateBuilder } = useBuilderTranslation(); const addFavorite = mutations.useAddBookmarkedItem(); diff --git a/src/components/common/Chatbox.tsx b/src/components/common/Chatbox.tsx index c31ab6fb2..938afe4f5 100644 --- a/src/components/common/Chatbox.tsx +++ b/src/components/common/Chatbox.tsx @@ -4,7 +4,6 @@ import { Loader } from '@graasp/ui'; import { hooks, mutations } from '../../config/queryClient'; import { CHATBOX_ID, CHATBOX_INPUT_BOX_ID } from '../../config/selectors'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; const { useItemChat, useAvatarUrl, useItemMemberships } = hooks; const { @@ -23,7 +22,7 @@ const Chatbox = ({ item }: Props): JSX.Element | null => { useItemMemberships(item.id); const members = itemPermissions?.map(({ member }) => member); const { data: currentMember, isLoading: isLoadingCurrentMember } = - useCurrentUserContext(); + hooks.useCurrentMember(); const { mutate: sendMessage } = usePostItemChatMessage(); const { mutate: editMessage } = usePatchItemChatMessage(); const { mutate: deleteMessage } = useDeleteItemChatMessage(); diff --git a/src/components/common/UserSwitchWrapper.tsx b/src/components/common/UserSwitchWrapper.tsx index 0302e9927..8cde19f62 100644 --- a/src/components/common/UserSwitchWrapper.tsx +++ b/src/components/common/UserSwitchWrapper.tsx @@ -3,7 +3,7 @@ import { UserSwitchWrapper as GraaspUserSwitch } from '@graasp/ui'; import { GRAASP_ACCOUNT_HOST, GRAASP_AUTH_HOST } from '@/config/env'; import { useBuilderTranslation } from '@/config/i18n'; -import { mutations } from '@/config/queryClient'; +import { hooks, mutations } from '@/config/queryClient'; import { HEADER_MEMBER_MENU_BUTTON_ID, HEADER_MEMBER_MENU_SEE_PROFILE_BUTTON_ID, @@ -13,7 +13,6 @@ import { } from '@/config/selectors'; import { BUILDER } from '../../langs/constants'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; import MemberAvatar from './MemberAvatar'; type Props = { @@ -21,7 +20,7 @@ type Props = { }; const UserSwitchWrapper = ({ ButtonContent }: Props): JSX.Element => { - const { data: member, isLoading } = useCurrentUserContext(); + const { data: member, isLoading } = hooks.useCurrentMember(); const { t: translateBuilder } = useBuilderTranslation(); const { mutateAsync: signOut } = mutations.useSignOut(); diff --git a/src/components/context/CurrentUserContext.tsx b/src/components/context/CurrentUserContext.tsx index b6768afbc..dc46a742d 100644 --- a/src/components/context/CurrentUserContext.tsx +++ b/src/components/context/CurrentUserContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useMemo } from 'react'; +import { ReactNode, createContext, useContext, useEffect } from 'react'; import i18n from '../../config/i18n'; import { hooks } from '../../config/queryClient'; @@ -12,30 +12,21 @@ const CurrentUserContext = createContext( ); type Props = { - children: JSX.Element | JSX.Element[]; + children: ReactNode; }; -export const CurrentUserContextProvider = ({ - children, -}: Props): JSX.Element => { - const query = useCurrentMember(); +export const CurrentUserContextProvider = ({ children }: Props): ReactNode => { + const { data } = useCurrentMember(); // update language depending on user setting - const lang = query?.data?.extra?.lang; + const lang = data?.extra?.lang; useEffect(() => { if (lang !== i18n.language) { i18n.changeLanguage(lang); } }, [lang]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const value = useMemo(() => query, [query.data]); - - return ( - - {children} - - ); + return children; }; export const useCurrentUserContext = (): CurrentUserContextType => diff --git a/src/components/item/ItemContent.tsx b/src/components/item/ItemContent.tsx index 98490ae8e..d6b35bbc2 100644 --- a/src/components/item/ItemContent.tsx +++ b/src/components/item/ItemContent.tsx @@ -41,7 +41,6 @@ import { buildFileItemId, } from '../../config/selectors'; import ErrorAlert from '../common/ErrorAlert'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; import { OutletType } from '../pages/item/type'; import FolderContent from './FolderContent'; import FileAlignmentSetting from './settings/file/FileAlignmentSetting'; @@ -212,7 +211,7 @@ const EtherpadContent = ({ item }: { item: EtherpadItemType }): JSX.Element => { * Main item renderer component */ const ItemContent = (): JSX.Element => { - const { data: member, isLoading, isError } = useCurrentUserContext(); + const { data: member, isLoading, isError } = hooks.useCurrentMember(); const { item, permission } = useOutletContext(); if (isLoading) { diff --git a/src/components/item/ItemMemberships.tsx b/src/components/item/ItemMemberships.tsx index 5b8f7a6bf..a0e691e10 100644 --- a/src/components/item/ItemMemberships.tsx +++ b/src/components/item/ItemMemberships.tsx @@ -9,7 +9,6 @@ import { ITEM_MEMBERSHIPS_CONTENT_ID } from '../../config/selectors'; import { BUILDER } from '../../langs/constants'; import { membershipsWithoutUser } from '../../utils/membership'; import MemberAvatar from '../common/MemberAvatar'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; type Props = { id?: string; @@ -19,7 +18,7 @@ type Props = { const ItemMemberships = ({ id, maxAvatar = 2 }: Props): JSX.Element | null => { const { t: translateBuilder } = useBuilderTranslation(); const { data: memberships, isError } = hooks.useItemMemberships(id); - const { data: currentUser } = useCurrentUserContext(); + const { data: currentUser } = hooks.useCurrentMember(); if (!id) { return null; diff --git a/src/components/item/publish/ItemPublishTab.tsx b/src/components/item/publish/ItemPublishTab.tsx index 89425c15b..3aeb3aa08 100644 --- a/src/components/item/publish/ItemPublishTab.tsx +++ b/src/components/item/publish/ItemPublishTab.tsx @@ -7,7 +7,6 @@ import { PublicationStatus } from '@graasp/sdk'; import { Loader, theme } from '@graasp/ui'; import SyncIcon from '@/components/common/SyncIcon'; -import { useCurrentUserContext } from '@/components/context/CurrentUserContext'; import { DataSyncContextProvider, useDataSyncContext, @@ -36,7 +35,7 @@ const { usePublicationStatus } = hooks; const ItemPublishTab = (): JSX.Element => { const { t } = useBuilderTranslation(); const { item, canAdmin } = useOutletContext(); - const { isLoading: isMemberLoading } = useCurrentUserContext(); + const { isLoading: isMemberLoading } = hooks.useCurrentMember(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const { status } = useDataSyncContext(); const { diff --git a/src/components/item/settings/AdminChatSettings.tsx b/src/components/item/settings/AdminChatSettings.tsx index 23c428f5e..7165dda28 100644 --- a/src/components/item/settings/AdminChatSettings.tsx +++ b/src/components/item/settings/AdminChatSettings.tsx @@ -3,9 +3,9 @@ import { PackedItem, PermissionLevel } from '@graasp/sdk'; import { MessageSquareTextIcon } from 'lucide-react'; import { useBuilderTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; import { BUILDER } from '@/langs/constants'; -import { useCurrentUserContext } from '../../context/CurrentUserContext'; import ClearChatButton from './ClearChatButton'; import ItemSettingProperty from './ItemSettingProperty'; @@ -16,7 +16,7 @@ type Props = { const AdminChatSettings = ({ item }: Props): JSX.Element | null => { const itemId = item.id; const { t } = useBuilderTranslation(); - const { data: currentMember } = useCurrentUserContext(); + const { data: currentMember } = hooks.useCurrentMember(); // only show export chat when user has admin right on the item const isAdmin = currentMember ? item?.permission === PermissionLevel.Admin diff --git a/src/components/item/sharing/ConfirmMembership.tsx b/src/components/item/sharing/ConfirmMembership.tsx index 102579338..406e999e3 100644 --- a/src/components/item/sharing/ConfirmMembership.tsx +++ b/src/components/item/sharing/ConfirmMembership.tsx @@ -14,10 +14,8 @@ import { } from '@graasp/sdk'; import { Button } from '@graasp/ui'; -import { useCurrentUserContext } from '@/components/context/CurrentUserContext'; - import { useBuilderTranslation } from '../../../config/i18n'; -import { mutations } from '../../../config/queryClient'; +import { hooks, mutations } from '../../../config/queryClient'; import { CONFIRM_MEMBERSHIP_DELETE_BUTTON_ID } from '../../../config/selectors'; import { BUILDER } from '../../../langs/constants'; import CancelButton from '../../common/CancelButton'; @@ -41,7 +39,7 @@ const DeleteItemDialog = ({ hasOnlyOneAdmin = false, }: Props): JSX.Element => { const { t: translateBuilder } = useBuilderTranslation(); - const { data: member } = useCurrentUserContext(); + const { data: member } = hooks.useCurrentMember(); const { mutate: deleteItemMembership } = mutations.useDeleteItemMembership(); diff --git a/src/components/main/ItemForbiddenScreen.tsx b/src/components/main/ItemForbiddenScreen.tsx index 30191c276..d40170ec0 100644 --- a/src/components/main/ItemForbiddenScreen.tsx +++ b/src/components/main/ItemForbiddenScreen.tsx @@ -3,14 +3,15 @@ import { Stack } from '@mui/material'; import { Button, ForbiddenContent } from '@graasp/ui'; +import { hooks } from '@/config/queryClient'; + import { useBuilderTranslation } from '../../config/i18n'; import { ITEM_LOGIN_SCREEN_FORBIDDEN_ID } from '../../config/selectors'; import { BUILDER } from '../../langs/constants'; import UserSwitchWrapper from '../common/UserSwitchWrapper'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; const ItemForbiddenScreen = (): JSX.Element => { - const { data: member } = useCurrentUserContext(); + const { data: member } = hooks.useCurrentMember(); const { t: translateBuilder } = useBuilderTranslation(); const ButtonContent = ( diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 4cb5991d9..9e084680c 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -22,6 +22,7 @@ import { APP_NAVIGATION_PLATFORM_SWITCH_ID, HEADER_APP_BAR_ID, } from '../../config/selectors'; +import MemberValidationBanner from '../alerts/MemberValidationBanner'; import CookiesBanner from '../common/CookiesBanner'; import UserSwitchWrapper from '../common/UserSwitchWrapper'; import MainMenu from './MainMenu'; @@ -101,6 +102,7 @@ const Main = ({ children }: Props): JSX.Element => { /> } > + {children} diff --git a/src/components/main/MainMenu.tsx b/src/components/main/MainMenu.tsx index 59fd71e45..97b50a9c1 100644 --- a/src/components/main/MainMenu.tsx +++ b/src/components/main/MainMenu.tsx @@ -14,6 +14,8 @@ import { import { MainMenu as GraaspMainMenu, LibraryIcon, MenuItem } from '@graasp/ui'; +import { hooks } from '@/config/queryClient'; + import { TUTORIALS_LINK } from '../../config/constants'; import { useBuilderTranslation } from '../../config/i18n'; import { @@ -23,7 +25,6 @@ import { RECYCLE_BIN_PATH, } from '../../config/paths'; import { BUILDER } from '../../langs/constants'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ '&:hover': { @@ -35,7 +36,7 @@ const MainMenu = (): JSX.Element => { const { t: translateBuilder } = useBuilderTranslation(); const navigate = useNavigate(); const { pathname } = useLocation(); - const { data: member } = useCurrentUserContext(); + const { data: member } = hooks.useCurrentMember(); const theme = useTheme(); const iconColor = theme.palette.action.active; diff --git a/src/components/pages/PublishedItemsScreen.tsx b/src/components/pages/PublishedItemsScreen.tsx index 7dc510605..66db29200 100644 --- a/src/components/pages/PublishedItemsScreen.tsx +++ b/src/components/pages/PublishedItemsScreen.tsx @@ -11,7 +11,6 @@ import { import { BUILDER } from '../../langs/constants'; import ErrorAlert from '../common/ErrorAlert'; import SelectTypes from '../common/SelectTypes'; -import { useCurrentUserContext } from '../context/CurrentUserContext'; import { useFilterItemsContext } from '../context/FilterItemsContext'; import { useItemSearch } from '../item/ItemSearch'; import ModeButton from '../item/header/ModeButton'; @@ -28,7 +27,7 @@ const PublishedItemsScreenContent = ({ searchText: string; }) => { const { t: translateBuilder } = useBuilderTranslation(); - const { data: member } = useCurrentUserContext(); + const { data: member } = hooks.useCurrentMember(); const { data: publishedItems, isLoading, diff --git a/src/components/pages/home/HomeScreen.tsx b/src/components/pages/home/HomeScreen.tsx index cced94fff..d7c86f2dd 100644 --- a/src/components/pages/home/HomeScreen.tsx +++ b/src/components/pages/home/HomeScreen.tsx @@ -17,7 +17,6 @@ import { hooks } from '../../../config/queryClient'; import { ACCESSIBLE_ITEMS_TABLE_ID } from '../../../config/selectors'; import { BUILDER } from '../../../langs/constants'; import SelectTypes from '../../common/SelectTypes'; -import { useCurrentUserContext } from '../../context/CurrentUserContext'; import { useFilterItemsContext } from '../../context/FilterItemsContext'; import { useLayoutContext } from '../../context/LayoutContext'; import FileUploader from '../../file/FileUploader'; @@ -36,7 +35,7 @@ import PageWrapper from '../PageWrapper'; const HomeScreenContent = ({ searchText }: { searchText: string }) => { const { t: translateBuilder } = useBuilderTranslation(); const { t: translateEnums } = useEnumsTranslation(); - const { data: currentMember } = useCurrentUserContext(); + const { data: currentMember } = hooks.useCurrentMember(); const { itemTypes } = useFilterItemsContext(); const [showOnlyMe, setShowOnlyMe] = useState(false); diff --git a/src/config/selectors.ts b/src/config/selectors.ts index 5aade7562..f9d6fc26a 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -420,3 +420,7 @@ export const SORTING_ORDERING_SELECTOR_ASC = '.lucide-arrow-down-narrow-wide'; export const UNBOOKMARK_ICON_SELECTOR = '[data-testid="BookmarkIcon"]'; export const BOOKMARK_ICON_SELECTOR = '[data-testid="BookmarkBorderOutlinedIcon"]'; + +export const MEMBER_VALIDATION_BANNER_ID = 'memberValidationBanner'; +export const MEMBER_VALIDATION_BANNER_CLOSE_BUTTON_ID = + 'memberValidationBannerCloseButton'; diff --git a/src/langs/constants.ts b/src/langs/constants.ts index 5eb6c9e46..c927bfc59 100644 --- a/src/langs/constants.ts +++ b/src/langs/constants.ts @@ -574,4 +574,8 @@ export const BUILDER = { HOME_SCREEN_LOAD_MORE_BUTTON: 'HOME_SCREEN_LOAD_MORE_BUTTON', PUBLISHED_ITEMS_EMPTY: 'PUBLISHED_ITEMS_EMPTY', PUBLISHED_ITEMS_NOT_FOUND_SEARCH: 'PUBLISHED_ITEMS_NOT_FOUND_SEARCH', + + MEMBER_VALIDATION_TITLE: 'MEMBER_VALIDATION_TITLE', + MEMBER_VALIDATION_DESCRIPTION: 'MEMBER_VALIDATION_DESCRIPTION', + MEMBER_VALIDATION_LINK_TEXT: 'MEMBER_VALIDATION_LINK_TEXT', }; diff --git a/src/langs/en.json b/src/langs/en.json index dba682813..d62f6827d 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -472,5 +472,8 @@ "HOME_SCREEN_LOAD_MORE_BUTTON": "Load More", "PUBLISHED_ITEMS_EMPTY": "You didn't publish any items.", "PUBLISHED_ITEMS_NOT_FOUND_SEARCH": "No published item found for {{search}}", - "item.order": "Order" + "item.order": "Order", + "MEMBER_VALIDATION_TITLE": "Account email needs to be verified", + "MEMBER_VALIDATION_DESCRIPTION": "In order to use all features of Graasp you need to validate your account email. You will find more information in the link below.", + "MEMBER_VALIDATION_LINK_TEXT": "Learn more" }