From 5b3ff91d1bb09f78ff19b92a4997d09b30d2597f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Thu, 19 Oct 2023 16:31:40 -0300 Subject: [PATCH 01/22] feat: move a11y features to CE (#30676) --- .../providers/UserProvider/UserProvider.tsx | 9 -- .../accessibility/AccessibilityPage.tsx | 80 ++++-------------- .../accessibility/HighContrastUpsellModal.tsx | 41 --------- .../MentionsWithSymbolUpsellModal.tsx | 40 --------- .../views/account/accessibility/themeItems.ts | 10 ++- .../rocketchat-i18n/i18n/en.i18n.json | 7 -- .../images/high-contrast-upsell-modal.png | Bin 13392 -> 0 bytes .../public/images/mentions-upsell-modal.png | Bin 9723 -> 0 bytes 8 files changed, 24 insertions(+), 163 deletions(-) delete mode 100644 apps/meteor/client/views/account/accessibility/HighContrastUpsellModal.tsx delete mode 100644 apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx delete mode 100644 apps/meteor/public/images/high-contrast-upsell-modal.png delete mode 100644 apps/meteor/public/images/mentions-upsell-modal.png diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 432a197671f3..09f631ffa6a6 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -10,7 +10,6 @@ import { Subscriptions, ChatRoom } from '../../../app/models/client'; import { getUserPreference } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback'; -import { useIsEnterprise } from '../../hooks/useIsEnterprise'; import { useReactiveValue } from '../../hooks/useReactiveValue'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; import { useCreateFontStyleElement } from '../../views/account/accessibility/hooks/useCreateFontStyleElement'; @@ -180,14 +179,6 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { } }, [preferedLanguage, setPreferedLanguage, setUserLanguage, user?.language, userLanguage, userId, setUserPreferences]); - const { data: license } = useIsEnterprise({ enabled: !!userId }); - - useEffect(() => { - if (!license?.isEnterprise && user?.settings?.preferences?.themeAppearence === 'high-contrast') { - setUserPreferences({ data: { themeAppearence: 'light' } }); - } - }, [license?.isEnterprise, setUserPreferences, user?.settings?.preferences?.themeAppearence]); - return ; }; diff --git a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx index 657548d5a1b9..c8179f08bef2 100644 --- a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx +++ b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx @@ -1,7 +1,6 @@ import { css } from '@rocket.chat/css-in-js'; import type { SelectOption } from '@rocket.chat/fuselage'; import { - Icon, FieldDescription, Accordion, Box, @@ -14,20 +13,16 @@ import { FieldRow, RadioButton, Select, - Tag, ToggleSwitch, } from '@rocket.chat/fuselage'; -import { useLocalStorage, useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import Page from '../../../components/Page'; -import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; import { getDirtyFields } from '../../../lib/getDirtyFields'; -import HighContrastUpsellModal from './HighContrastUpsellModal'; -import MentionsWithSymbolUpsellModal from './MentionsWithSymbolUpsellModal'; import { fontSizes } from './fontSizes'; import type { AccessibilityPreferencesData } from './hooks/useAcessibilityPreferencesValues'; import { useAccessiblityPreferencesValues } from './hooks/useAcessibilityPreferencesValues'; @@ -36,14 +31,9 @@ import { themeItems as themes } from './themeItems'; const AccessibilityPage = () => { const t = useTranslation(); - const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); const preferencesValues = useAccessiblityPreferencesValues(); - const { data: license } = useIsEnterprise(); - const isEnterprise = license?.isEnterprise; - const { themeAppearence } = preferencesValues; - const [, setPrevTheme] = useLocalStorage('prevTheme', themeAppearence); const createFontStyleElement = useCreateFontStyleElement(); const displayRolesEnabled = useSetting('UI_DisplayRoles'); @@ -82,7 +72,6 @@ const AccessibilityPage = () => { onError: (error) => dispatchToastMessage({ type: 'error', message: error }), onSettled: (_data, _error, { data: { fontSize } }) => { reset(currentData); - dirtyFields.themeAppearence && setPrevTheme(themeAppearence); dirtyFields.fontSize && fontSize && createFontStyleElement(fontSize); }, }); @@ -102,45 +91,25 @@ const AccessibilityPage = () => { - {themes.map(({ id, title, description, ...item }, index) => { - const showCommunityUpsellTriggers = 'isEEOnly' in item && item.isEEOnly && !isEnterprise; - + {themes.map(({ id, title, description }, index) => { return ( - {t.has(title) ? t(title) : title} - {showCommunityUpsellTriggers && ( - - - - {t('Enterprise')} - - - )} + {t(title)} { - if (showCommunityUpsellTriggers) { - return ( - setModal( setModal(null)} />)} - checked={false} - /> - ); - } - return onChange(id)} checked={value === id} />; - }} + render={({ field: { onChange, value, ref } }) => ( + onChange(id)} checked={value === id} /> + )} /> - {t.has(description) ? t(description) : description} + {t(description)} ); @@ -165,32 +134,15 @@ const AccessibilityPage = () => { - - {t('Mentions_with_@_symbol')} - {!isEnterprise && ( - - - - {t('Enterprise')} - - - )} - + {t('Mentions_with_@_symbol')} - {isEnterprise ? ( - ( - - )} - /> - ) : ( - setModal( setModal(null)} />)} - checked={false} - /> - )} + ( + + )} + /> void }) => { - const t = useTranslation(); - - const isAdmin = useRole('admin'); - const { handleGoFullyFeatured, handleTalkToSales } = useUpsellActions(); - - if (!isAdmin) { - return ( - - ); - } - return ( - - ); -}; -export default HighContrastUpsellModal; diff --git a/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx b/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx deleted file mode 100644 index b92ca74d0f6e..000000000000 --- a/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRole, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import GenericUpsellModal from '../../../components/GenericUpsellModal'; -import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; - -const MentionsWithSymbolUpsellModal = ({ onClose }: { onClose: () => void }) => { - const t = useTranslation(); - - const isAdmin = useRole('admin'); - const { handleGoFullyFeatured, handleTalkToSales } = useUpsellActions(); - - if (!isAdmin) { - return ( - - ); - } - return ( - - ); -}; -export default MentionsWithSymbolUpsellModal; diff --git a/apps/meteor/client/views/account/accessibility/themeItems.ts b/apps/meteor/client/views/account/accessibility/themeItems.ts index 62bf3830d952..f16d9128503d 100644 --- a/apps/meteor/client/views/account/accessibility/themeItems.ts +++ b/apps/meteor/client/views/account/accessibility/themeItems.ts @@ -1,4 +1,11 @@ -export const themeItems = [ +import type { TranslationKey } from '@rocket.chat/ui-contexts'; + +type ThemeItem = { + id: string; + title: TranslationKey; + description: TranslationKey; +}; +export const themeItems: ThemeItem[] = [ { id: 'light', title: 'Theme_light', @@ -10,7 +17,6 @@ export const themeItems = [ description: 'Theme_dark_description', }, { - isEEOnly: true, id: 'high-contrast', title: 'Theme_high_contrast', description: 'Theme_high_contrast_description', diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 7ef10e0988b0..46805a1f0e3e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1100,7 +1100,6 @@ "Condition": "Condition", "Commit_details": "Commit Details", "Completed": "Completed", - "Compliant_use_of_color": "Compliant use of color", "Computer": "Computer", "Conference_call_apps": "Conference call apps", "Conference_call_has_ended": "_Call has ended._", @@ -1865,7 +1864,6 @@ "EmojiCustomFilesystem_Description": "Specify how emojis are stored.", "Empty_no_agent_selected": "Empty, no agent selected", "Empty_title": "Empty title", - "Empower_access_move_beyond_color": "Empower access, move beyond color", "Enable": "Enable", "Enable_Auto_Away": "Enable Auto Away", "Enable_CSP": "Enable Content-Security-Policy", @@ -3364,7 +3362,6 @@ "Mentions_only": "Mentions only", "Mentions_with_@_symbol": "Mentions with @ symbol", "Mentions_with_@_symbol_description": "Mentions notify and highlight messages for groups or specific users, facilitating targeted communication.\n\nThe screen reader functionality is optimized when the \"@\" symbol is employed in the mention feature. This ensures that users relying on screen readers can easily interpret and engage with these mentions.", - "Mentions_with_symbol_upsell_description": "Unlock the full potential of a barrier-free business with our premium accessibility feature.\n\nSay goodbye to color-related compliance challenges all while aligning with WCAG (Web Content Accessibility Guidelines) and BITV (Barrierefreie Informationstechnik-Verordnung) standards.\n\nThe use of the @ symbol makes it easier for screen readers to navigate and interact with your content, ensuring the best experience for all users.", "Merge_Channels": "Merge Channels", "message": "message", "Message": "Message", @@ -5976,10 +5973,6 @@ "Theme_high_contrast": "High contrast", "Theme_high_contrast_description": "Maximum tonal differentiation with bold colors and sharp contrasts provide enhanced accessibility.", "Highlighted_chosen_word": "Highlighted chosen word", - "High_contrast_upsell_title": "Enable high contrast theme", - "High_contrast_upsell_subtitle": "Enhance your team’s reading experience", - "High_contrast_upsell_description": "Especially designed for individuals with visual impairments or conditions such as color vision deficiency, low vision, or sensitivity to low contrast.\n\nThis theme increases contrast between text and background elements, making content more distinguishable and easier to read.", - "High_contrast_upsell_annotation": "Talk to your workspace admin about enabling the high contrast theme for everyone.", "Join_your_team": "Join your team", "Create_a_password": "Create a password", "Create_an_account": "Create an account", diff --git a/apps/meteor/public/images/high-contrast-upsell-modal.png b/apps/meteor/public/images/high-contrast-upsell-modal.png deleted file mode 100644 index b761a1b0b76c81cda36007039682c3a5493b8448..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13392 zcmc(GbySq!_bw?gfHa6A-Q6wS9nv)--5ny0NJ%#XC?H+ZA*pl;2nYj=2n^COblw;J zeB-X)y?@?y|6tAHT{GvLcb~nVv-h)~6RoA8hzoiQLPA2qRaTPIK|(?SBOxIpVLkw! z5c>|30UuayN=BYYNZ3U8f5=GLkVn8nWKSJM8KmlQicR1jG+SwPX(Xgi3E0;!(2V>B6y!z2XA3%}N?xv7rL+PRDXb1DKjyUT3V#LQ zO4#0MY@CgbkMq^@wbuunj$XstVF`P!?PBeS9dWTP|2&v@SMc)6BsYJ|Bb>Lzux)bU z{*>s$XA|e0uK1Z6B{qa7WzgNn#F2$bo5&}&{FFHw^sY+0Lb$R-UZM}shaoQb)(@Rs zNU~B8tMUj(lfwoUa0I>#Ce3f~V)gxJL<1)4-zNfv8puy@9z4K0M8=k-V5>P-h`Iw2 zep&fOP18wW$ICQFK$t4BL_JKguoy$_xkX%C)+2+4W)H3lIl&miBGiWeynI>vest4z z7*Y5zn;MG-n8!iEjJSyXrXYeBJ34(Cu4P09%_-Gnz9EpQy&V*wzxgn_`G&XT$uJ}c z-wVC?1NHsV%Eh9Jin8t{-yIxV5f-6#jnZyA;D z2TaV?E(!S{pGPhKfZ_c~^vxvRM3M`$=cRP{Cw6KG)&H1Q7Cr8Mn~H3{vw*{Ux~W4+ z(OEu>+7Xz_cK@d4&kBzXD;h0(pbh^9*GJ3JEeUyH&Dt}{^zxg>BXagkAyvs#k(=&y zJ z+%Am%@78#O9vSEMV1YT58h*d$ky7U&18krLe=eIfjklp6SgPCI4<><@fG4G=GFX0e z^CPE-Y~n#iL*?DH|5(>fXdH76|KCf|K*sI{MP5oJNrxTx?R()6KK#gF0hy$fIAatN(7ztn@x8e*e|N;q1JnNc6zB{ z@}*Vd+qU<{5rgwkz!CFm6Li)sb9wRjYSTY;SJ~vqA9iNrtiFM0ZGi3e_kd_6T{8Obu z@k%8_P<}}<~8JYO%DQMA#?<_ z|Nnv^@j9ime>`?&3BOX(1J_?l{wIs)B@mAo+VF*HHf~(>`^lBYzWpcUxf?{oiMovw z?IxvPhGbiEM)3-L7%hwBEuk8(y3{HOdDp|*q}fvtUc{wa8)}cj=s86toP`?uvtV@d zKCoOM1l2VE1xchx@=_oPEvrQ8z{60`cTjE&ufeOw*vrD>ERxj?O2J3GzEixvoe!Sr zNwOws1oh$7T;4k0K6EiL|A3PkY)}GscUcOmVUv{SowL~y#`sP}1NgWchLi!An4U&8 zF_w-zGKJeks4>dXN15?`!FRfgiFMgt1pLVvpZq?_9#NN3i-nQcMdw#_Tc$kPX&r3~ zgFuV<4JS)jm_(IB{rUN{&(Xw)n$>bP%xv}y9sTR3!7buqpY4Xy{RQ);#r&uA#tg|> z>n_#FE4@?qt6g)kh(!6)5CUFVIG#IO)JDflN~!{3U}R!M@mllFgE#nug)ZuK2O4#K z#8r_EaskPIp#IC_=&eTv{}&YGo3!RpqGdB4A4fJ zzNv5x44c}tXj$64Hr3aL3r)Y7c;_TPIF?4|yfK7_DZy=q^~Sxj6FW7Y+vKhmsLOtm zfiFZ3E|zsX3lAe+b-B9eIU1%bLg~8|k0mlN^ux&ax8_f-{LyGV<3eJ0Lr{B3WTE+K zn8c^SA3TrvpA$R z0Y5_NMtLG(ZG1THA-S&A=Pi<}-?rgI{P8SyFNKmXw55b4y?U9%6qi4B9`gjP>Nz`dWBk;kl$P zs=?`narotpEG zx1V=ytAYj0A66p7Y~tecPB2GUUoawkSYw~}4#xjY35q9}l^T`gvX9H=cC!WRe)|$B zO8b12jIZ}CnT~Ek7aUfgZO5e9=E?dxSrOjD{a6UHJ${HiT^=zc%yjF7tTltz(5hEJ zy?)J&+eg~Ns-06^Dz&uUIO{LG?UgUQ`qmxO>iSVGld|&Uexq&=y&QIKyzAYo$#ZOyNzgGQ;=irSB6kfrAxu<85Ed(8HvM$DR;9F7oS|fFa5>E}5n{Q(dmWO_rvgJg-HA!Vq=}D)&1TUco@^!u zK;q+fID{!fXu~xBJnmOD!LD0hHKn}A zD534R5<~aIY5bbVC1NLVGPY4chIo$Xq@kEYjPrPUh94i6qRNRkyld1#Ljy)Hi2rl+Qf~H z?ItU%2&}3Q$S-*wsL4)ty4g%zz}VS?l^=s-l=vcAnI1=~MtF(&KLC4H03j{LFP)q# zm0GjYqCrPb!^+I87?IwXn@D?{Mr(P2ankTfbL2b6!_x?U`PbI)8Yx_T)bzbc+};aq zGSbQY7ikgFY`` zZ=;`LO%CrY978thaZR%re`_Jr9T4NKQ7mHF$D!w}{!&;LCh+>-QZm`vg6cw=^ihI> zyv4*!0ZGANPx@5~Q)2obi>%s-YO10|r!ZY|XWdRs2hg!H zFmSjBY@k4P)16{kI{(Dc!7PGb^UzxfK{t;MOcPjZoQQ19p#*a^>lj}OxC)E^UVjo^xhYJT;vw4|IieZbxGI ztAjN4r=MN9vTMvRb_A_2q&wvXjofSG$bTxf8j(Xb{FG_&@MTKVqEvZZ6Md-P zTjwe#$F1wrtWQ6l?t5$L*68iTs@0(?Xf<{h z1_4y-ElbIAom@4dHp1QINebnjCkq0edyC=lk&Q0P<-6On>>9~aJx^Iwkn$1*g}C#> zyr6}}!zSNMDC|{E>-I}3fs(T`b+hxNqjg^h2~JLwzyiFL6Lq}E@CC%peIWYHF3Exl zqeT|8vv>N-hV>~clfo75!bOUbD)odBF8dJ+56X_@reX$!mUuN;qqPcDrBgz3jyN#r z)J56k6ce(s=X>SP)_(XfA+p@%4i5v>6EvvLQNi^zYUhwgP+V+Fw?;llcStHvu4s7V zoJXG#4=-Vqtva}sru_}Gh41IxT_1+uDQCgGJ9DKWb}L&mj_^>;XI7jRolc#nKMEUJ zUP!iGhUJ#7Dv#%DW*@#pC>y4vQAIdyW4%>WgnwVola`*M8M?4H){jRmq#k-GlS0Si zv%WoR;=+@EwNv7&b3v$)zbushd4E+O>)Ba%c(GvbuZ-gd)a?IM&^%KkZgxS`Z@+4UJ^}b#$W5VTu-(E?)SUxYd;InlJb}UNmod2$| zbDh>n>>K)EKwWGc`5jaSsUe%m3&{)J`SHV!z7Praov0{Re3L{!szB>v?Ll9Q#(W=iUOb1^pEapS=YzCJU zAm93S6m*uoH^g?!!9;%b;$Q${%w%#=BGUkFOvK7L}K3gri{Nmm(*SZt7aFNCuqKdnM~P@G-lqqd5$ zl6RQ--SMZy%~w3DRZ`4CK#%5GeubLN?+Vh0sUE(=5A6Pf(|@Y{24*EJYsw~e(A3Qh znP-@Ck}-Q0T?7T534)XZMCjy_#;jEQ;Rml_mWmYJD`Qyx&LP4kCo%hwG|vjApB#e{ z%>s@eYYNxP2}wkWag^5CrrkcQaBXW1mHRj#n_O{w5qy=dJ@A?)dk@LVcajVpOCOt% z12l!WnVs1-^)9)u>>k?`hYyYEczpS8$FpY#!R|&uKD`VKmqXIR*2zWbH)UvxR&8pm z(2QjR&l@Mkwb1HmwI)Z%B)_xt$rTJ#Z*qs2pd+$3!?hEwFor%ZGe+>o)}t_TgOYbp z0K3MChO^wK|IV=wS#NKqomYAOfOPUzKDR|$JI$IfBUn2TVsi2Upg+R(obto&*@ogO zs?55Lm!i_K9Sm|JE6MLb{i|L!v5W#kNsD*6DQ9K-z{PGRhZ$A;}Lx#EJ#(&;L5>!eL$Bc zdDl7tfYPgx99){JFC*1&1adLr5=DW$=YyG_@zH`)tl+6F51wJVTluue$@N7|b(;T8 z@0-}?-5U5dy^=IGvdWKMA&>@(2gn-SO%gBDmHY(>#*^CW-T5gV(|?O`SGD-oo26Lz zX>#!6NO0Ti#)RY%iLBDLmaupe&(b*Se-36szlBny*bDcine+;S8l#WzD!@#rd@K2A zAF+CV@=g*ZD=q=N!5UTp6{cZMXWyLeM0{VP57*UBx4l$(N?2lWTN2oaV854)?~M@_kOI3aAw|zAr32X zaQqvo>>49{0F6hM5SyAh%^}N5%$3e1 zx&?D{-vq(lQz!8!e&qa!>^MtXC$jwDgalex@$N{(`M03!u6xWQW4Ye zRy_bx^hre^B&(4gzn!xD!Pb=A@;7CcLDy4M*NTX}V1%nGOmj4G;9cZvLDBfPRJR2e zM;D5tji4aw=KR(%NCzSpKAP41Sf-q3x^qYN+Eg`&iJyAq(Uyw(D=om4wW# zgHpd{+tSJ_Qf4&Qx035X&i6Yv%n|)UOHLAZ2V|@)7QL%vc~Qh)&VEFEoM>Wy!7>>_ zlr>6BY=xoKj53Q+fgVk&yxJ%X;*G^Q_+(kk0`f?^;4zvJ4LE%YUu=D=BH3I(jvydd{@+bru&MMuOQ~#1d zE_h0hhvWXO#RCNO?OZynTPJ3UCld$sI!z@GdhqyYQ!1(Bxa6&8=^^f%Z~VuOd(_U+ zvaP<^hj&SlAYRs?FecQsHL`DHWYWwXq6Ylb#d9%xL8uF#qYU^?eZPRH4mDlZdr)i- z5hWq48vlt_OKO$lP(*TfI`6#t>0wsI{e~iTJP_tik(GI=-f1W;R4lLegJA(x_MgiA zwJ*IEOEtn0Hm+UsS=_qwLuSI(7R&Jfa?dY@1VbcOUb=ufFssQ4YTYJGx|j4Z8Pz@BX~`a!$G0-C-hRbtjZ>s7BgMT~^fahUPP^XS zSRk>kU_RoF9yX245sFLn$h^thA>Df2jl#|JnraM7aw`_LH8?JxV9_=Gd9+HWd-!#$ z+=a-UQtRfy&&`r5a*wov)6TQorWNT4$NB9PBEmm zur^9?k+9MxQp9kZlEpV2M>OerUM6^#UZ=73kh*IbKCaOWPAjL!=*IZ7M^7cy&VJqbb7@h(NsU4oh`OM4<+JTYae$> zPf%TWc0MlM5OV?#^UXEDJ-uh4{CZNfTx}Wj*-|}tfbUF?M^*)XiAZoVmrYI_9u?piIDPaCua5xs zD4S|X158r;ZAfoDeH_bEq;he6r0iuA3)Hr-2@`^OjsCPr&mWR7+s8Z zEX?+3Q(#AawN{8Kwj!Qr??>Pw50iiJ6K7{cOZ;^DPQ_u1pz}!l5?{ST6Nj(FnTKf9iOYa-T-T8co9NbhmR+NVUo&a*7HgF_BFSQj*u_;L5uUr)9Li$ z)rBICd8X(`6rwhq%~eBcQRNMdTP<65s);bA(`heLy@linv{r<0UfdIGTfjhzq$8t= zN6Y2x=VA0=Y2c6?m(P=*FK6tlYk^ZvHZsvWGmgn-OAM6Bq%!YS9pc^L_1S&EPvxq4T9u)^3i*FVhv}jK0 zC-Q3%d$38$)mV9Kr4_}3O|~1Y(X*Xrru0381gI!bL=uAdyZP%;PL;;soxFTSmF88$ zVRs)T&kMXynXH~f@&2sV7?jUan?1C+)SvfbqcZY^{3xp0%SIqUjccl`ubgJ}YiE73 zP({&OsS!d)oYo>UR+OwDFMNN_g|}G;L-A8iS`lVRLuw_ z>UA0dN4X`oL(P}l+s&ynxer$lb@dWgwYd2ObK{v0aphG+-w8IW5EnWAa-st=s7js1 zvUzNIS-@e1sb=6WGNc$iT%aCaX z&18}<%yWP7Im?v=P;XxMOWvp#S5Xhpv%eP2Em(>ns*4guuf)^GuKUC7{2p((IT5Yj zC^YfZnP-I$c&F7wh_TU|knk0a>@rx#n*xX5z9MMO8D3HooaVp8^?7;ma01rDeqrT# zbS7oqHpyqc`m%UK?)pP5hE)(TK3pH@bLfWwDY3RNx{%4ZP* z!!lMla}LaB>@b^O^lM2cTg3w%oNpvRn?x)sC$QbT9C#=CmNK3CGV&wO{2Q-z<0Q_4 z&r88~OgJ=-JaeDKHv}?k*#B@;0!A78^tI&qp%AL2h!W=yep;FnLw=_Dqq#*FJS(GF zQ2iiA*GOXdEysjs4pbGi`8Ss@EtZtr>F60D975z*7DkbWZSHJ#@4Nr@!+0_g>-;z? z#I^R@7{wP+u2~veDXoA}_((>hMuY=S$J6qMa%j+%186`q^J1{p=L9J@=e|oP+K6{0 zd8Vo_=!z1Prz+LsbAVJe_OTQ69fFk+I2U~V_@6UazYPx+K~JwJ(oKHa{O*T1SUdlf zfLJxvTPx~(+g`lYWP+jXFJI^oG20d{N~G>}!KcHX@ZmJu-RaOucus}=P)9>CXN)H1}Rsty!ONtCYscZQv zEad;9R>fdXHG06b#W4(rG^ree4B#m~M1D*d_Cw08%uer!nTcQ<;GO;I$ zUp`1%;$%h^IIoOW1l>)RE<`8{7Morj#yY-3CA17aS-{S6KFhz`9v;p(7`+pz1LdfI z47wLDE63(S)w|hYJEctStzT~+CZW#0P?Yj70eYOj^%GM3FK@}fl zZ$>4cy!;wk3p{2 zdp77WFeA>Gmkn&*gMh!-q+9|Tl-?l2F8Pa(3H{(`O#wm zoD92Fl>gwB-7>WtE;@pFKN_f@xj=-Kr1ZwbMH^StC!+FLv5FU*z+z~&hG3{7+DVTq z(Rs(w!9jKbH=WWl@YyDl^zT(6G(bMgE2*Nnkcr`xGzo*TE~q z^jz1lgyh|zfre9;E2X&W?fg=(OZ8m_jQ>XLc2FXtPO#$K0`_Y8`mOJcFQUE6W9apH zBdlSf>*hC}{@$5y@HGd5sX15RZauBbi85elVt!-dc0&=D#`7rJFjtk^9cq^>81GpD zHs=ex@0JN1b(c``1L<7qywB!Q{z4bqggFm@2a{7bch2Eb3R6Ny8_FncpZG3a}>@drAv=4R#9c{WJXR$guyXzF< zZoX-sBi=j9$iI%epqk7df!z!>@4#Xw#BFa^y6zHbtVl*9GyyMVwSsQpR9m7LV%IrO zN>>nmiXS)`T)aS77-@ByQl5X8)hA$E&`hoQlUkpo3z@8|w%m+5!@F)LQ{W>BuN&RE z?ue^A*3-Di$~c%~4D(#?R`5tuxiVt&FH9<;)|2$AKOe2r4rf)y6C@LoO3Z0td!Ue_ z5B-;tpd2g`n4<*L{}h>@lQ$+svcJ=L>W#hu>|@vhW7@wuH$C(a348^sjv?7K=o|I3 z2n{>6O}KSi57N9|=EH{Om4R)pM;JiHxr=UJZ~^dRi!7oLziDXQ?0=%(kvbE`|ITti zq*e*3q2VzRl36IgStt(T6`v#=$X7h~w<2U;*ar%|^gX&1o5>*g3KRd^U#{kcKlB^g z(l!EMH;27VtQ95(cfZ;dP;ColWQ~mISukMH#I-qF`rX5+6hNO+;t$u41yl6wO}PBk z%wOlp^CTz^R;CEoAgctgx4t?tUI{X&F=KNTGDc@xs8N9*Zv^w2|L}1LJ_&j|M|!rM zXTH=mIpe5++N=oL-RL^FQVpInlL^A$2@M8lwoKy#2$Q|k5u`u&z#4*zki|xFU~2nI z&~+=hCr%+_rg74UH+F9tAA425a-73vsc0axs}R--&XLsGgqv~2G2jF>YRP9d>^e+`@sn|yHbAX@rG5t2 z?V3$W$X^X@&GIs+ZK08-HTf@%aha4yES-Ls%M89gl}F!M?+U8-ze(mNI$Ev>R!u-* zEQZ!RJ1xa3Y`433%J5_5=g;mP6v7bd3iGbZev8=de4L@a7J0)$93p%KX;m8#a@P4ty< z_{hzs=DFSz1O9tP1@^V{fanTIR!?d45p{)Cz^WTl|_vuV*gK{hvxxXKgW34mvjPyR0O^K~CfR*xxHy@bFgJSNP((7-hAxDlNWJhTis;{ODT750*BNSF-sa)+m zX9UZxLG$Qb)@24znCZZ>6UAxo`P0^A z#rhojvxSGAtL59PYw<)KzY)=`Q)`7x4S$&xmByasx!ANLZnY8OWKyX8N_J`68|W-GkN~n7%{52S z43hcu2J7umQI!_4k~n7&4p1SU$j6>VUvWa$I{exTzXjNn29vHzyfBP2K~+ZHGyGlx zM2NPMwhN2SRZRX19v7J{TF(C6CtTSFQw57f5QsCYGCP;CJ{yYP{-+G)c6q;Yw-K!uO3{rh=wqR zF9n7;*%SiS5ZQJfS>rJxplDG5I;7q8o6MlS<|?sAbhKDyL^W&Ws&HKenLh+IfMdu& zTf^jvZot&1o*#D-1STkWvWra?#jsYaTj~A~$Vvg324oO~2C%;a+KiYhqs!N>;*Y<~ zDAUQYDW_(dVbrUB8)_7WmP?5_nzZGz;&j7j38XHm0R$c=P&6W6jlXgE%}cYkmN}}U zPX9_D;w=$K<-8u9$oo&w{!IIQkQJAfnEePR#=vrspZM`VLF?_`jVYo`*cH0QDdx|~ zIM@+1aU5W%-4bCZL+C;&56Wz&KO=LjPyVBw;A<&sN8Q=& zEfn_3`w+8&tv1{+u8%cL&ELS8Bt>|`Y4peVWwI6YJJ%MH9obJiyq+F-TXiuNxd6e9 zTJnGz1rR8?jP*4oJ^eY2Y{7`(i-J!yge@5~DGK+Lk4qfOTzC~);P*89OBQS{>{l98 zF_zFqwfcj+bl6x`;o+mgN6n1iirc>qU~cU_l=_f5O|x`h^QP7MOC@HEfa0)7fOd=@ z6Bf;vBFGs|pJb04e}hqhn1ll3{*ad?(^r>})qJ5x57oNf>LT>5I62*R23YiMGxR*n zF6!M@txnj2*se;QArT{}4>DiY@76xRo@Y4MLSkR^>}Ou@p15z zD|Yuw=9?G4p|B?(CBv!&Fuzlo7(!Du46F)OuB%Y!o1 z8@y6`7|d~d4JTE{$Gp-r20IF~6_xwBrgjl`@i)8uMY@`ACiH9{S3UBt$8t}(srWSR zUK4;bKk@ePXqD8;1=`gI-&HL(_FUQZ^v#e)`R10iLFPj}0nB&&eiPx!rTXn%M%zk;=lU}}1Yn*8>;G-br zZ>kNpQ|Y6-%V`;)J5daG(K^2v^&Pz=Ar;q@$aF%09RJsqNTi>jTol=^CUTSzcm>hJ>|>_T8kIE$Q9ps zG`zqr7VHr}ERiqLzv!CdI-NhHyW+a#|0d82I=r89vG@9k=fK7M{?FGF-{YYxzXVpB zwmWUTQl1Y;p>R|^kcpwb2QDTJ*9vAO&(xONUXQT&h< zx-zl2Ycadc)4b`x^uPhj91#YmAc}g4HdoSShvXsu&633+a$AOCu#9g)U#hspS%1mz zW>cR^`3d#g9~nDcmuE^QCNpWy!}E25N4ppOqjZn#&FQV40SJI>*rV0eD+OhADi!%w z_xT%3Vyhm3u?vSXS4n6~e4l%E?76c6jp~`|nKx7Q>v?*id{aT=X}!u{8`k&NyS^xc0avQZg>o4@yPXG#n*7JXenlb7bMjYd z4%>W0FAC;mx(Q7YsqG#7qoX&)M+2lfj!{5HcQ0uc0Qz`;rK>q?Ogr# ziD~3i?uoUDyGb$|o7<6fS|h{l^XPpvXs?~>(JsAjjzfs~wT79oMr#{q?>cAsxgyiXx-n-*ewDy4ZAcAxj&$Kz_F4b4^RIx z2=4i8^hamRiT7wCK@U>@47ywK2Q8wZEx1M*!Vl_CI$O zx@lXOJ=>&HMH-&i0uJT5)$}dC@o_ZZ_oTkVm@dL$nyRU z=dGPscN|O1nZuq`5Wx>!mJ5^tO+M_oq?=2`c#f6_Xua?6m{?cuwxLXG-A6~n_t86y ZrjobB@s5`tfeS!L%JLd=)iU6){{_?5;TZq` diff --git a/apps/meteor/public/images/mentions-upsell-modal.png b/apps/meteor/public/images/mentions-upsell-modal.png deleted file mode 100644 index a71b3b837d9a77866e4b2c490543b1f608050871..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9723 zcmc(Fc{r5q+y5;>mZSyQpA^bk*|Me*(Gaq)W#6(6S!R$2l|3>s_O-0p34=tq$MgP<-@oti{xQcK_cix*-`90spYuFF=jWVf_Y8G8+4$K20N}i% zcgq9-jyM7U1Hj5mA2}V;&rAP0=BH;B2mtJ-4nGV)b`BqXkRi}S_XbciEVx9!U~hXO4jsqYOB=VW0t-1b`VwIP&@Kk4Hc6z1-yZ5Q+FMxKBzz>{F35YtNd-$D;hi1&pQ|AHTI+9tL&7=})cecN(UUGV&us(F*(O7r@4tjs?8^!(X z80jx0Cog@Cp94SOZ&Y!dPCkg7x-m(kV&e~7zt>R9-ukadCi(PZ4UP7(Z8&dt4I`Z^XK=COacXFGS} z?8DQ)xA5OH<#}NZe$|1O43aqnLLcq_xi>*bVE@N^FNIcM<$Ilur=3lr&3bRskHH!l z*|o{{pJfdpbYm&;Zh!ySb9B~6_aCe9MztwzeJoAWK8s#^Mmno$U;Kc+rF;V~X(?WN zk=xow0DzDvt_J{7dE8M9^b2o4hHuVSxM8hIY$own+LfMo{hD1AMQ`N<7K?rfjG-?& zaJ6APR$aLj7_83H|DI!X+ZO&o@B8}>s}H()0-E>*hWVYdg4v?2m&Yg#$BWE7JT%dGe-V$Ypv=81CCi@hOim=_B?>jZ zZsxJXLJAF&^DpF31bI#T04stFQrZJ>{hl7L3T-V=jfMd(&=-%8WhJlgO3C=n6DaP> zU?G+2h_k;)@6M%9L}X?9&U7_2il-;cZV(9PVG}`TP9^pgn^DIa3707j*p_V%P&u!| zBSS-3d)?{0(ozx$(Q;SH9+KyEP(1qZ?URLkas-5kWavyuXna3T>YIDz{?eR}d0_R6 zj`dOvW?QAzaNpW*D+?Q(9j*8_&;?>JCD{$09wYpu2ZLDWhy9Rd+e_Q(n zo_VNO9Y5(-$sYR1kKvWg)t9Z;@)KK?;?r7VLmT2!)RiNKhZsLCUsX}~?HS&F#iC8W z&@T%IFC~oGS-qb44hl1pmFa;0p8)u(od2U2mjJ*qLB-Hnb*kFR9xe(#T{0x5cIY^7 z^-eH7RQuyRw+};Y)!h3n3noVZ=)QE7r7YmD-{v{XKu<+)pR+GpUtyyMKTDU>lOl+F zjE9l=>Jj<>4g-~ol6qx@bgM3=u~$l-{2dt*0S@|v3miw5%Zo%0A7;J@A${Qe_pDpS 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 From 79f6388678aa1241628656792e8a03565107a037 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 19 Oct 2023 16:57:17 -0300 Subject: [PATCH 02/22] test: add cleanup to users tests (#30654) --- apps/meteor/tests/data/api-data.js | 9 +- apps/meteor/tests/data/custom-fields.js | 18 +- apps/meteor/tests/data/users.helper.js | 17 +- apps/meteor/tests/end-to-end/api/01-users.js | 466 ++++++++++-------- .../tests/end-to-end/api/10-subscriptions.js | 12 +- 5 files changed, 275 insertions(+), 247 deletions(-) diff --git a/apps/meteor/tests/data/api-data.js b/apps/meteor/tests/data/api-data.js index d08e4cc50c54..b311af16e764 100644 --- a/apps/meteor/tests/data/api-data.js +++ b/apps/meteor/tests/data/api-data.js @@ -13,10 +13,10 @@ export function wait(cb, time) { return () => setTimeout(cb, time); } -export const apiUsername = `api${username}`; -export const apiEmail = `api${email}`; -export const apiPublicChannelName = `api${publicChannelName}`; -export const apiPrivateChannelName = `api${privateChannelName}`; +export const apiUsername = `api${username}-${Date.now()}`; +export const apiEmail = `api${email}-${Date.now()}`; +export const apiPublicChannelName = `api${publicChannelName}-${Date.now()}`; +export const apiPrivateChannelName = `api${privateChannelName}-${Date.now()}`; export const apiRoleNameUsers = `api${roleNameUsers}`; export const apiRoleNameSubscriptions = `api${roleNameSubscriptions}`; @@ -25,7 +25,6 @@ export const apiRoleScopeSubscriptions = `${roleScopeSubscriptions}`; export const apiRoleDescription = `api${roleDescription}`; export const reservedWords = ['admin', 'administrator', 'system', 'user']; -export const targetUser = {}; export const channel = {}; export const group = {}; export const message = {}; diff --git a/apps/meteor/tests/data/custom-fields.js b/apps/meteor/tests/data/custom-fields.js index 2509dddf5d84..e2e175429b4c 100644 --- a/apps/meteor/tests/data/custom-fields.js +++ b/apps/meteor/tests/data/custom-fields.js @@ -1,4 +1,4 @@ -import { getCredentials, request, api, credentials } from './api-data.js'; +import { credentials, request, api } from './api-data.js'; export const customFieldText = { type: 'text', @@ -7,18 +7,12 @@ export const customFieldText = { maxLength: 10, }; -export function setCustomFields(customFields, done) { - getCredentials((error) => { - if (error) { - return done(error); - } +export function setCustomFields(customFields) { + const stringified = customFields ? JSON.stringify(customFields) : ''; - const stringified = customFields ? JSON.stringify(customFields) : ''; - - request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200).end(done); - }); + return request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200); } -export function clearCustomFields(done = () => {}) { - setCustomFields(null, done); +export function clearCustomFields() { + return setCustomFields(null); } diff --git a/apps/meteor/tests/data/users.helper.js b/apps/meteor/tests/data/users.helper.js index 92425902cb5b..82ab8446547d 100644 --- a/apps/meteor/tests/data/users.helper.js +++ b/apps/meteor/tests/data/users.helper.js @@ -33,16 +33,13 @@ export const login = (username, password) => }); }); -export const deleteUser = (user) => - new Promise((resolve) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(resolve); - }); +export const deleteUser = async (user) => + request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: user._id, + }); export const getUserByUsername = (username) => new Promise((resolve) => { diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index b8343dc015da..eaafc97527a3 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -5,23 +5,12 @@ import { expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { sleep } from '../../../lib/utils/sleep'; -import { - getCredentials, - api, - request, - credentials, - apiEmail, - apiUsername, - targetUser, - log, - wait, - reservedWords, -} from '../../data/api-data.js'; +import { getCredentials, api, request, credentials, apiEmail, apiUsername, log, wait, reservedWords } from '../../data/api-data.js'; import { MAX_BIO_LENGTH, MAX_NICKNAME_LENGTH } from '../../data/constants.ts'; import { customFieldText, clearCustomFields, setCustomFields } from '../../data/custom-fields.js'; import { imgURL } from '../../data/interactions'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { adminEmail, preferences, password, adminUsername } from '../../data/user'; import { createUser, login, deleteUser, getUserStatus, getUserByUsername } from '../../data/users.helper.js'; @@ -39,11 +28,48 @@ async function joinChannel(userCredentials, roomId) { }); } +const targetUser = {}; + describe('[Users]', function () { this.retries(0); before((done) => getCredentials(done)); + before('should create a new user', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: apiEmail, + name: apiUsername, + username: apiUsername, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', apiUsername); + expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.not.have.nested.property('user.e2e'); + + expect(res.body).to.not.have.nested.property('user.customFields'); + + targetUser._id = res.body.user._id; + targetUser.username = res.body.user.username; + }); + }); + + after(async () => { + await deleteUser(targetUser); + }); + it('enabling E2E in server and generating keys to user...', async () => { await updateSetting('E2E_Enable', true); await request @@ -71,145 +97,101 @@ describe('[Users]', function () { }); describe('[/users.create]', () => { - before((done) => clearCustomFields(done)); - after((done) => clearCustomFields(done)); + before(async () => clearCustomFields()); + after(async () => clearCustomFields()); + + it('should create a new user with custom fields', async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${apiUsername}`; + const email = `customField_${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + let user; - it('should create a new user', async () => { await request .post(api('users.create')) .set(credentials) .send({ - email: apiEmail, - name: apiUsername, - username: apiUsername, + email, + name: username, + username, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, + customFields, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', apiUsername); - expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.emails[0].address', email); expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); expect(res.body).to.not.have.nested.property('user.e2e'); - expect(res.body).to.not.have.nested.property('user.customFields'); - - targetUser._id = res.body.user._id; - targetUser.username = res.body.user.username; + user = res.body.user; }); - await request - .post(api('login')) - .send({ - user: apiUsername, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200); + await deleteUser(user); }); - it('should create a new user with custom fields', (done) => { - setCustomFields({ customFieldText }, (error) => { - if (error) { - return done(error); - } - - const username = `customField_${apiUsername}`; - const email = `customField_${apiEmail}`; - const customFields = { customFieldText: 'success' }; - + function failCreateUser(name) { + it(`should not create a new user if username is the reserved word ${name}`, (done) => { request .post(api('users.create')) .set(credentials) .send({ - email, - name: username, - username, + email: `create_user_fail_${apiEmail}`, + name: `create_user_fail_${apiUsername}`, + username: name, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, - customFields, }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(400) .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', username); - expect(res.body).to.have.nested.property('user.emails[0].address', email); - expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', username); - expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); - expect(res.body).to.not.have.nested.property('user.e2e'); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); }) .end(done); }); - }); + } - function failCreateUser(name) { - it(`should not create a new user if username is the reserved word ${name}`, (done) => { - request + function failUserWithCustomField(field) { + it(`should not create a user if a custom field ${field.reason}`, async () => { + await setCustomFields({ customFieldText }); + + const customFields = {}; + customFields[field.name] = field.value; + + await request .post(api('users.create')) .set(credentials) .send({ - email: `create_user_fail_${apiEmail}`, - name: `create_user_fail_${apiUsername}`, - username: name, + email: `customField_fail_${apiEmail}`, + name: `customField_fail_${apiUsername}`, + username: `customField_fail_${apiUsername}`, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, + customFields, }) .expect('Content-Type', 'application/json') .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); - }) - .end(done); - }); - } - - function failUserWithCustomField(field) { - it(`should not create a user if a custom field ${field.reason}`, (done) => { - setCustomFields({ customFieldText }, (error) => { - if (error) { - return done(error); - } - - const customFields = {}; - customFields[field.name] = field.value; - - request - .post(api('users.create')) - .set(credentials) - .send({ - email: `customField_fail_${apiEmail}`, - name: `customField_fail_${apiUsername}`, - username: `customField_fail_${apiUsername}`, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - customFields, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); - }) - .end(done); - }); + expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); + }); }); } @@ -226,12 +208,16 @@ describe('[Users]', function () { }); describe('users default roles configuration', () => { + const users = []; + before(async () => { await updateSetting('Accounts_Registration_Users_Default_Roles', 'user,admin'); }); after(async () => { await updateSetting('Accounts_Registration_Users_Default_Roles', 'user'); + + await Promise.all(users.map((user) => deleteUser(user))); }); it('should create a new user with default roles', (done) => { @@ -256,6 +242,8 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', username); expect(res.body.user.roles).to.have.members(['user', 'admin']); + + users.push(res.body.user); }) .end(done); }); @@ -283,6 +271,8 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', username); expect(res.body.user.roles).to.have.members(['guest']); + + users.push(res.body.user); }) .end(done); }); @@ -292,6 +282,10 @@ describe('[Users]', function () { describe('[/users.register]', () => { const email = `email@email${Date.now()}.com`; const username = `myusername${Date.now()}`; + let user; + + after(async () => deleteUser(user)); + it('should register new user', (done) => { request .post(api('users.register')) @@ -308,6 +302,7 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.username', username); expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', 'name'); + user = res.body.user; }) .end(done); }); @@ -331,9 +326,11 @@ describe('[Users]', function () { }); describe('[/users.info]', () => { - after(() => { - updatePermission('view-other-user-channels', ['admin']); - updatePermission('view-full-other-user-info', ['admin']); + after(async () => { + await Promise.all([ + updatePermission('view-other-user-channels', ['admin']), + updatePermission('view-full-other-user-info', ['admin']), + ]); }); it('should return an error when the user does not exist', (done) => { @@ -476,26 +473,30 @@ describe('[Users]', function () { }); it('should correctly route users that have `ufs` in their username', async () => { + const ufsUsername = `ufs-${Date.now()}`; + let user; + await request .post(api('users.create')) .set(credentials) .send({ - email: 'me@email.com', + email: `me-${Date.now()}@email.com`, name: 'testuser', - username: 'ufs', + username: ufsUsername, password: '1234', }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); + user = res.body.user; }); await request .get(api('users.info')) .set(credentials) .query({ - username: 'ufs', + username: ufsUsername, }) .expect('Content-Type', 'application/json') .expect(200) @@ -503,9 +504,11 @@ describe('[Users]', function () { expect(res.body).to.have.property('success', true); expect(res.body.user).to.have.property('type', 'user'); expect(res.body.user).to.have.property('name', 'testuser'); - expect(res.body.user).to.have.property('username', 'ufs'); + expect(res.body.user).to.have.property('username', ufsUsername); expect(res.body.user).to.have.property('active', true); }); + + await deleteUser(user); }); }); describe('[/users.getPresence]', () => { @@ -549,10 +552,10 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('full', true); - expect(res.body) - .to.have.property('users') - .to.have.property('0') - .to.deep.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + + const user = res.body.users.find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); }) .end(done); }); @@ -583,10 +586,10 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('full', true); - expect(res.body) - .to.have.property('users') - .to.have.property('0') - .to.deep.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + + const user = res.body.users.find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); }) .end(done); }); @@ -599,68 +602,59 @@ describe('[Users]', function () { let user2; let user2Credentials; - before((done) => { - const createDeactivatedUser = async () => { - const username = `deactivated_${Date.now()}${apiUsername}`; - const email = `deactivated_+${Date.now()}${apiEmail}`; - - const userData = { - email, - name: username, - username, - password, - active: false, - }; - - deactivatedUser = await createUser(userData); - - expect(deactivatedUser).to.not.be.null; - expect(deactivatedUser).to.have.nested.property('username', username); - expect(deactivatedUser).to.have.nested.property('emails[0].address', email); - expect(deactivatedUser).to.have.nested.property('active', false); - expect(deactivatedUser).to.have.nested.property('name', username); - expect(deactivatedUser).to.not.have.nested.property('e2e'); + before(async () => { + const username = `deactivated_${Date.now()}${apiUsername}`; + const email = `deactivated_+${Date.now()}${apiEmail}`; + + const userData = { + email, + name: username, + username, + password, + active: false, }; - createDeactivatedUser().then(done); - }); - before((done) => - setCustomFields({ customFieldText }, async (error) => { - if (error) { - return done(error); - } - - const username = `customField_${Date.now()}${apiUsername}`; - const email = `customField_+${Date.now()}${apiEmail}`; - const customFields = { customFieldText: 'success' }; + deactivatedUser = await createUser(userData); - const userData = { - email, - name: username, - username, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - customFields, - }; + expect(deactivatedUser).to.not.be.null; + expect(deactivatedUser).to.have.nested.property('username', username); + expect(deactivatedUser).to.have.nested.property('emails[0].address', email); + expect(deactivatedUser).to.have.nested.property('active', false); + expect(deactivatedUser).to.have.nested.property('name', username); + expect(deactivatedUser).to.not.have.nested.property('e2e'); + }); - user = await createUser(userData); + before(async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${Date.now()}${apiUsername}`; + const email = `customField_+${Date.now()}${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + const userData = { + email, + name: username, + username, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + customFields, + }; - expect(user).to.not.be.null; - expect(user).to.have.nested.property('username', username); - expect(user).to.have.nested.property('emails[0].address', email); - expect(user).to.have.nested.property('active', true); - expect(user).to.have.nested.property('name', username); - expect(user).to.have.nested.property('customFields.customFieldText', 'success'); - expect(user).to.not.have.nested.property('e2e'); + user = await createUser(userData); - return done(); - }), - ); + expect(user).to.not.be.null; + expect(user).to.have.nested.property('username', username); + expect(user).to.have.nested.property('emails[0].address', email); + expect(user).to.have.nested.property('active', true); + expect(user).to.have.nested.property('name', username); + expect(user).to.have.nested.property('customFields.customFieldText', 'success'); + expect(user).to.not.have.nested.property('e2e'); + }); - after((done) => clearCustomFields(done)); + after(async () => clearCustomFields()); before(async () => { user2 = await createUser({ joinDefaultChannels: false }); @@ -668,6 +662,8 @@ describe('[Users]', function () { }); after(async () => { + await deleteUser(deactivatedUser); + await deleteUser(user); await deleteUser(user2); user2 = undefined; @@ -1281,26 +1277,23 @@ describe('[Users]', function () { }); }); - it('should update the user name when the required permission is applied', (done) => { - updatePermission('edit-other-user-info', ['admin']).then(() => { - updateSetting('Accounts_AllowUsernameChange', false).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: targetUser._id, - data: { - username: 'fake.name', - }, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + it('should update the user name when the required permission is applied', async () => { + await Promise.all([updatePermission('edit-other-user-info', ['admin']), updateSetting('Accounts_AllowUsernameChange', false)]); + + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + username: `fake.name.${Date.now()}`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); }); - }); }); it('should return an error when trying update user real name and it is not allowed', (done) => { @@ -2297,6 +2290,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('should return an username suggestion', (done) => { request .get(api('users.getUsernameSuggestion')) @@ -2326,7 +2321,7 @@ describe('[Users]', function () { const testUsername = `test-username-123456-${+new Date()}`; let targetUser; let userCredentials; - it('register a new user...', (done) => { + before((done) => { request .post(api('users.register')) .set(credentials) @@ -2343,7 +2338,7 @@ describe('[Users]', function () { }) .end(done); }); - it('Login...', (done) => { + before((done) => { request .post(api('login')) .send({ @@ -2360,6 +2355,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('should return true if the username is the same user username set', (done) => { request .get(api('users.checkUsernameAvailability')) @@ -2410,7 +2407,7 @@ describe('[Users]', function () { const testUsername = `testuser${+new Date()}`; let targetUser; let userCredentials; - it('register a new user...', (done) => { + before((done) => { request .post(api('users.register')) .set(credentials) @@ -2427,7 +2424,7 @@ describe('[Users]', function () { }) .end(done); }); - it('Login...', (done) => { + before((done) => { request .post(api('login')) .send({ @@ -2444,6 +2441,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('Enable "Accounts_AllowDeleteOwnAccount" setting...', (done) => { request .post('/api/v1/settings/Accounts_AllowDeleteOwnAccount') @@ -2472,23 +2471,22 @@ describe('[Users]', function () { .end(done); }); - it('should delete user own account when the SHA256 hash is in upper case', (done) => { - createUser().then((user) => { - login(user.username, password).then((createdUserCredentials) => { - request - .post(api('users.deleteOwnAccount')) - .set(createdUserCredentials) - .send({ - password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + it('should delete user own account when the SHA256 hash is in upper case', async () => { + const user = await createUser(); + const createdUserCredentials = await login(user.username, password); + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); }); - }); + + await deleteUser(user); }); it('should return an error when trying to delete user own account if user is the last room owner', async () => { @@ -2542,6 +2540,9 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); it('should delete user own account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { @@ -2594,6 +2595,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); it('should assign a new owner to the room if the last room owner is deleted', async () => { @@ -2661,6 +2664,8 @@ describe('[Users]', function () { expect(res.body.roles[0].roles).to.eql(['owner']); expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); }); @@ -2763,6 +2768,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should delete user account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { @@ -2813,6 +2820,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should delete user account when logged user has "delete-user" permission', async () => { @@ -2893,6 +2902,8 @@ describe('[Users]', function () { expect(res.body.roles[0].roles).to.eql(['owner']); expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); }); @@ -3241,6 +3252,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should set other user status to inactive if the user is the last owner of a room and `confirmRelinquish` is set to `true`', async () => { @@ -3305,6 +3318,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should set other user as room owner if the last owner of a room is deactivated and `confirmRelinquish` is set to `true`', async () => { @@ -3396,6 +3411,8 @@ describe('[Users]', function () { expect(res.body.roles[1].roles).to.eql(['owner']); expect(res.body.roles[1].u).to.have.property('_id', credentials['X-User-Id']); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should return an error when trying to set other user active status and has not the necessary permission(edit-other-user-active-status)', (done) => { @@ -3464,6 +3481,8 @@ describe('[Users]', function () { expect(user).to.have.property('roles'); expect(user.roles).to.be.an('array').of.length(2); expect(user.roles).to.include('user', 'livechat-agent'); + + await deleteUser(testUser); }); }); @@ -3516,6 +3535,10 @@ describe('[Users]', function () { .end(done); }); + after(async () => { + await deleteUser(testUser); + }); + it('should fail to deactivate if user doesnt have edit-other-user-active-status permission', (done) => { updatePermission('edit-other-user-active-status', []).then(() => { request @@ -3563,7 +3586,7 @@ describe('[Users]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('count', 1); }) .end(done); }); @@ -3690,7 +3713,7 @@ describe('[Users]', function () { updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); }); - describe('[without permission]', () => { + describe('[without permission]', function () { let user; let userCredentials; let user2; @@ -3711,6 +3734,12 @@ describe('[Users]', function () { roomId = await createChannel(userCredentials, `channel.autocomplete.${Date.now()}`); }); + after(async () => { + await deleteRoom({ type: 'c', roomId }); + await deleteUser(user); + await deleteUser(user2); + }); + it('should return an empty list when the user does not have any subscription', (done) => { request .get(api('users.autocomplete?selector={}')) @@ -4126,6 +4155,10 @@ describe('[Users]', function () { .then(() => done()); }); + after(async () => { + await deleteUser(testUser); + }); + it('should list both channels', (done) => { request .get(api('users.listTeams')) @@ -4151,14 +4184,19 @@ describe('[Users]', function () { describe('[/users.logout]', () => { let user; let otherUser; + let userCredentials; + before(async () => { user = await createUser(); otherUser = await createUser(); }); + before(async () => { + userCredentials = await login(user.username, password); + }); + after(async () => { await deleteUser(user); await deleteUser(otherUser); - user = undefined; }); it('should throw unauthorized error to user w/o "logout-other-user" permission', (done) => { @@ -4187,7 +4225,7 @@ describe('[Users]', function () { it('should logout the requester', (done) => { updatePermission('logout-other-user', []).then(() => { - request.post(api('users.logout')).set(credentials).expect('Content-Type', 'application/json').expect(200).end(done); + request.post(api('users.logout')).set(userCredentials).expect('Content-Type', 'application/json').expect(200).end(done); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/10-subscriptions.js b/apps/meteor/tests/end-to-end/api/10-subscriptions.js index f547895eb8a4..531291a99216 100644 --- a/apps/meteor/tests/end-to-end/api/10-subscriptions.js +++ b/apps/meteor/tests/end-to-end/api/10-subscriptions.js @@ -236,7 +236,8 @@ describe('[Subscriptions]', function () { before(async () => { user = await createUser({ username: 'testthread123', password: 'testthread123' }); threadUserCredentials = await login('testthread123', 'testthread123'); - request + + const res = await request .post(api('chat.sendMessage')) .set(threadUserCredentials) .send({ @@ -244,14 +245,13 @@ describe('[Subscriptions]', function () { rid: testChannel._id, msg: 'Starting a Thread', }, - }) - .end((_, res) => { - threadId = res.body.message._id; }); + + threadId = res.body.message._id; }); - after((done) => { - deleteUser(user).then(done); + after(async () => { + await deleteUser(user); }); it('should mark threads as read', async () => { From 93a0859e87f0a115152e79e224fb42c10748c5fe Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Fri, 20 Oct 2023 02:23:53 +0530 Subject: [PATCH 03/22] fix: Unnecessary username validation on account profile form (#30677) --- .changeset/empty-files-know.md | 5 +++++ .../client/views/account/profile/AccountProfileForm.tsx | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/empty-files-know.md diff --git a/.changeset/empty-files-know.md b/.changeset/empty-files-know.md new file mode 100644 index 000000000000..5e6fb8f751b2 --- /dev/null +++ b/.changeset/empty-files-know.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix unnecessary username validation on accounts profile form diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index ed97b95caae8..65b3a0967d49 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -66,6 +66,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle const { email, avatar, username } = watch(); const previousEmail = user ? getUserEmailAddress(user) : ''; + const previousUsername = user?.username || ''; const isUserVerified = user?.emails?.[0]?.verified ?? false; const mutateConfirmationEmail = useMutation({ @@ -87,6 +88,10 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle return; } + if (username === previousUsername) { + return; + } + if (!namesRegex.test(username)) { return t('error-invalid-username'); } From b85df55030f9aaf351d15fe66ee0ec008bfc9691 Mon Sep 17 00:00:00 2001 From: csuadev <72958726+csuadev@users.noreply.github.com> Date: Thu, 19 Oct 2023 16:46:06 -0500 Subject: [PATCH 04/22] fix: UI issue on marketplace filters (#30660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> --- .changeset/cyan-mangos-do.md | 5 +++ .../CategoryFilter/CategoryDropDownAnchor.tsx | 41 ++++++++++++------- .../RadioDropDown/RadioDownAnchor.tsx | 22 ++++++---- 3 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 .changeset/cyan-mangos-do.md diff --git a/.changeset/cyan-mangos-do.md b/.changeset/cyan-mangos-do.md new file mode 100644 index 000000000000..e188686c82d5 --- /dev/null +++ b/.changeset/cyan-mangos-do.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: UI issue on marketplace filters diff --git a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx index 91e66683e66f..b3e43fda942f 100644 --- a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx @@ -1,4 +1,6 @@ -import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { Button } from '@rocket.chat/fuselage'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import colorTokens from '@rocket.chat/fuselage-tokens/colors.json'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, MouseEventHandler } from 'react'; import React, { forwardRef } from 'react'; @@ -15,33 +17,42 @@ const CategoryDropDownAnchor = forwardRef {selectedCategoriesCount > 0 && ( {selectedCategoriesCount} diff --git a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx index 36e4ff55657f..f480b2a60280 100644 --- a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx @@ -1,4 +1,5 @@ -import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { Button } from '@rocket.chat/fuselage'; +import { Box, Icon } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; import React, { forwardRef } from 'react'; @@ -14,22 +15,25 @@ const RadioDownAnchor = forwardRef(functi return ( {selected} From b9a3381d9394d39bb22629c9fc951c2407e00db8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 19 Oct 2023 16:47:32 -0600 Subject: [PATCH 05/22] test: `ShouldPreventAction` (#30690) --- ee/packages/license/src/license.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 989be7b69ae1..b637ee33ddfd 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -41,6 +41,30 @@ it('should prevent if the counter is equal or over the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); +it.skip('should not prevent an action if another limit is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder() + .withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]) + .withLimits('monthlyActiveContacts', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2); + await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); +}); + describe('Validate License Limits', () => { describe('prevent_action behavior', () => { describe('during the licensing apply', () => { From 53cf1f5940c76e6d4df132e3ca8e7118206d1ea5 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 19 Oct 2023 19:25:00 -0600 Subject: [PATCH 06/22] refactor: Move functions out of `livechat.js` (#30664) --- .../app/apps/server/bridges/livechat.ts | 2 +- .../app/livechat/imports/server/rest/sms.js | 2 +- .../app/livechat/server/api/v1/message.ts | 16 +- .../app/livechat/server/api/v1/visitor.ts | 2 +- apps/meteor/app/livechat/server/lib/Helper.ts | 6 +- .../app/livechat/server/lib/Livechat.js | 191 +--------------- .../app/livechat/server/lib/LivechatTyped.ts | 207 +++++++++++++++++- .../server/methods/returnAsInquiry.ts | 4 +- .../server/methods/sendMessageLivechat.ts | 28 +-- apps/meteor/app/livechat/server/startup.ts | 10 +- .../server/lib/AutoTransferChatScheduler.ts | 4 +- .../EmailInbox/EmailInbox_Incoming.ts | 3 +- 12 files changed, 238 insertions(+), 237 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 5b6c76257667..70802f280095 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -44,7 +44,7 @@ export class AppLivechatBridge extends LivechatBridge { throw new Error('Invalid token for livechat message'); } - const msg = await Livechat.sendMessage({ + const msg = await LivechatTyped.sendMessage({ guest: this.orch.getConverters()?.get('visitors').convertAppVisitor(message.visitor), message: await this.orch.getConverters()?.get('messages').convertAppMessage(message), agent: undefined, diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.js b/apps/meteor/app/livechat/imports/server/rest/sms.js index 7ecb3b3fc100..9d2bee133784 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.js +++ b/apps/meteor/app/livechat/imports/server/rest/sms.js @@ -182,7 +182,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { }; try { - const msg = SMSService.response.call(this, await Livechat.sendMessage(sendMessage)); + const msg = SMSService.response.call(this, await LivechatTyped.sendMessage(sendMessage)); setImmediate(async () => { if (sms.extra) { if (sms.extra.fromCountry) { diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 0d5a22b90d89..1dcf54e403a6 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -17,7 +17,6 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { loadMessageHistory } from '../../../../lib/server/functions/loadMessageHistory'; import { settings } from '../../../../settings/server'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; -import { Livechat } from '../../lib/Livechat'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; @@ -67,7 +66,7 @@ API.v1.addRoute( }, }; - const result = await Livechat.sendMessage(sendMessage); + const result = await LivechatTyped.sendMessage(sendMessage); if (result) { const message = await Messages.findOneById(_id); if (!message) { @@ -176,7 +175,7 @@ API.v1.addRoute( throw new Error('invalid-message'); } - const result = await Livechat.deleteMessage({ guest, message }); + const result = await LivechatTyped.deleteMessage({ guest, message }); if (result) { return API.v1.success({ message: { @@ -272,10 +271,15 @@ API.v1.addRoute( visitor = await LivechatVisitors.findOneEnabledById(visitorId); } + const guest = visitor; + if (!guest) { + throw new Error('error-invalid-token'); + } + const sentMessages = await Promise.all( this.bodyParams.messages.map(async (message: { msg: string }): Promise<{ username: string; msg: string; ts: number }> => { const sendMessage = { - guest: visitor, + guest, message: { _id: Random.id(), rid, @@ -288,8 +292,8 @@ API.v1.addRoute( }, }, }; - // @ts-expect-error -- Typings on sendMessage are wrong - const sentMessage = await Livechat.sendMessage(sendMessage); + + const sentMessage = await LivechatTyped.sendMessage(sendMessage); return { username: sentMessage.u.username, msg: sentMessage.msg, diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 84f7b96e155d..6488d34eab7a 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -121,7 +121,7 @@ API.v1.addRoute('livechat/visitor/:token', { } const { _id } = visitor; - const result = await Livechat.removeGuest(_id); + const result = await LivechatTyped.removeGuest(_id); if (!result.modifiedCount) { throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor'); } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 63cbbd6998ef..4acbdf5090ad 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -437,7 +437,7 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T return false; } - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); const { servedBy } = roomTaken; if (servedBy) { @@ -537,7 +537,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi logger.debug( `Routing algorithm doesn't support auto assignment (using ${RoutingManager.methodName}). Chat will be on department queue`, ); - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); return RoutingManager.unassignAgent(inquiry, departmentId); } @@ -573,7 +573,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi } } - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); if (oldServedBy) { // if chat is queued then we don't ignore the new servedBy agent bcs at this // point the chat is not assigned to him/her and it is still in the queue diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 837a8eb7309b..b208c9fb5e85 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -8,11 +8,9 @@ import { LivechatRooms, LivechatInquiry, Subscriptions, - Messages, LivechatDepartment as LivechatDepartmentRaw, Rooms, Users, - ReadReceipts, } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -25,15 +23,11 @@ import { i18n } from '../../../../server/lib/i18n'; import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { FileUpload } from '../../../file-upload/server'; -import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; -import { sendMessage } from '../../../lib/server/functions/sendMessage'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; import { Analytics } from './Analytics'; -import { normalizeTransferredByData, parseAgentCustomFields, updateDepartmentAgents } from './Helper'; -import { Livechat as LivechatTyped } from './LivechatTyped'; +import { parseAgentCustomFields, updateDepartmentAgents } from './Helper'; import { RoutingManager } from './RoutingManager'; const logger = new Logger('Livechat'); @@ -43,41 +37,6 @@ export const Livechat = { logger, - async sendMessage({ guest, message, roomInfo, agent }) { - const { room, newRoom } = await LivechatTyped.getRoom(guest, message, roomInfo, agent); - if (guest.name) { - message.alias = guest.name; - } - return Object.assign(await sendMessage(guest, message, room), { - newRoom, - showConnecting: this.showConnecting(), - }); - }, - - async deleteMessage({ guest, message }) { - Livechat.logger.debug(`Attempting to delete a message by visitor ${guest._id}`); - check(message, Match.ObjectIncluding({ _id: String })); - - const msg = await Messages.findOneById(message._id); - if (!msg || !msg._id) { - return; - } - - const deleteAllowed = settings.get('Message_AllowDeleting'); - const editOwn = msg.u && msg.u._id === guest._id; - - if (!deleteAllowed || !editOwn) { - Livechat.logger.debug('Cannot delete message: not allowed'); - throw new Meteor.Error('error-action-not-allowed', 'Message deleting not allowed', { - method: 'livechatDeleteMessage', - }); - } - - await deleteMessage(message, guest); - - return true; - }, - async saveGuest(guestData, userId) { const { _id, name, email, phone, livechatData = {} } = guestData; Livechat.logger.debug(`Saving data for visitor ${_id}`); @@ -234,111 +193,6 @@ export const Livechat = { return Message.saveSystemMessage('livechat_navigation_history', roomId, `${pageTitle} - ${pageUrl}`, user, extraData); }, - async saveTransferHistory(room, transferData) { - Livechat.logger.debug(`Saving transfer history for room ${room._id}`); - const { departmentId: previousDepartment } = room; - const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; - - check( - transferredBy, - Match.ObjectIncluding({ - _id: String, - username: String, - name: Match.Maybe(String), - type: String, - }), - ); - - const { _id, username } = transferredBy; - const scopeData = scope || (nextDepartment ? 'department' : 'agent'); - Livechat.logger.debug(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); - - const transfer = { - transferData: { - transferredBy, - ts: new Date(), - scope: scopeData, - comment, - ...(previousDepartment && { previousDepartment }), - ...(nextDepartment && { nextDepartment }), - ...(transferredTo && { transferredTo }), - }, - }; - - const type = 'livechat_transfer_history'; - const transferMessage = { - t: type, - rid: room._id, - ts: new Date(), - msg: '', - u: { - _id, - username, - }, - groupable: false, - }; - - Object.assign(transferMessage, transfer); - - await sendMessage(transferredBy, transferMessage, room); - }, - - async returnRoomAsInquiry(rid, departmentId, overrideTransferData = {}) { - Livechat.logger.debug(`Transfering room ${rid} to ${departmentId ? 'department' : ''} queue`); - const room = await LivechatRooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (!room.open) { - throw new Meteor.Error('room-closed', 'Room closed', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (room.onHold) { - throw new Meteor.Error('error-room-onHold', 'Room On Hold', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (!room.servedBy) { - return false; - } - - const user = await Users.findOneById(room.servedBy._id); - if (!user || !user._id) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - // find inquiry corresponding to room - const inquiry = await LivechatInquiry.findOne({ rid }); - if (!inquiry) { - return false; - } - - const transferredBy = normalizeTransferredByData(user, room); - Livechat.logger.debug(`Transfering room ${room._id} by user ${transferredBy._id}`); - const transferData = { roomId: rid, scope: 'queue', departmentId, transferredBy, ...overrideTransferData }; - try { - await this.saveTransferHistory(room, transferData); - await RoutingManager.unassignAgent(inquiry, departmentId); - } catch (e) { - this.logger.error(e); - throw new Meteor.Error('error-returning-inquiry', 'Error returning inquiry to the queue', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); - - return true; - }, - async getLivechatRoomGuestInfo(room) { const visitor = await LivechatVisitors.findOneEnabledById(room.v._id); const agent = await Users.findOneById(room.servedBy && room.servedBy._id); @@ -481,55 +335,12 @@ export const Livechat = { return removeUserFromRolesAsync(user._id, ['livechat-manager']); }, - async removeGuest(_id) { - const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); - if (!guest) { - throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { - method: 'livechat:removeGuest', - }); - } - - await this.cleanGuestHistory(guest); - return LivechatVisitors.disableById(_id); - }, - async setUserStatusLivechat(userId, status) { const user = await Users.setLivechatStatus(userId, status); callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); return user; }, - async setUserStatusLivechatIf(userId, status, condition, fields) { - const user = await Users.setLivechatStatusIf(userId, status, condition, fields); - callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); - return user; - }, - - async cleanGuestHistory(guest) { - const { token } = guest; - - // This shouldn't be possible, but just in case - if (!token) { - throw new Error('error-invalid-guest'); - } - - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - const cursor = LivechatRooms.findByVisitorToken(token, extraQuery); - for await (const room of cursor) { - await Promise.all([ - FileUpload.removeFilesByRoomId(room._id), - Messages.removeByRoomId(room._id), - ReadReceipts.removeByRoomId(room._id), - ]); - } - - await Promise.all([ - Subscriptions.removeByVisitorToken(token), - LivechatRooms.removeByVisitorToken(token), - LivechatInquiry.removeByVisitorToken(token), - ]); - }, - async saveDepartmentAgents(_id, departmentAgents) { check(_id, String); check(departmentAgents, { diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 293b15e8d63c..32cb5c83acd9 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -15,6 +15,9 @@ import type { ILivechatDepartment, AtLeast, TransferData, + MessageAttachment, + IMessageInbox, + ILivechatAgentStatus, } from '@rocket.chat/core-typings'; import { UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -34,13 +37,15 @@ import { import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import moment from 'moment-timezone'; -import type { FindCursor, UpdateFilter } from 'mongodb'; +import type { Filter, FindCursor, UpdateFilter } from 'mongodb'; import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; import { canAccessRoomAsync } from '../../../authorization/server'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; +import { FileUpload } from '../../../file-upload/server'; +import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; import * as Mailer from '../../../mailer/server/api'; @@ -89,6 +94,40 @@ type OfflineMessageData = { host?: string; }; +export interface ILivechatMessage { + token: string; + _id: string; + rid: string; + msg: string; + file?: { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; + }; + files?: { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; + }[]; + attachments?: MessageAttachment[]; + alias?: string; + groupable?: boolean; + blocks?: IMessage['blocks']; + email?: IMessageInbox['email']; +} + +type AKeyOf = { + [K in keyof T]?: T[K]; +}; + const dnsResolveMx = util.promisify(dns.resolveMx); class LivechatClass { @@ -1123,6 +1162,172 @@ class LivechatClass { void callbacks.run('livechat.offlineMessage', data); }); } + + async sendMessage({ + guest, + message, + roomInfo, + agent, + }: { + guest: ILivechatVisitor; + message: ILivechatMessage; + roomInfo: { + source?: IOmnichannelRoom['source']; + [key: string]: unknown; + }; + agent?: SelectedAgent; + }) { + const { room, newRoom } = await this.getRoom(guest, message, roomInfo, agent); + if (guest.name) { + message.alias = guest.name; + } + return Object.assign(await sendMessage(guest, message, room), { + newRoom, + showConnecting: this.showConnecting(), + }); + } + + async removeGuest(_id: string) { + const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); + if (!guest) { + throw new Error('error-invalid-guest'); + } + + await this.cleanGuestHistory(guest); + return LivechatVisitors.disableById(_id); + } + + async cleanGuestHistory(guest: ILivechatVisitor) { + const { token } = guest; + + // This shouldn't be possible, but just in case + if (!token) { + throw new Error('error-invalid-guest'); + } + + const cursor = LivechatRooms.findByVisitorToken(token); + for await (const room of cursor) { + await Promise.all([ + FileUpload.removeFilesByRoomId(room._id), + Messages.removeByRoomId(room._id), + ReadReceipts.removeByRoomId(room._id), + ]); + } + + await Promise.all([ + Subscriptions.removeByVisitorToken(token), + LivechatRooms.removeByVisitorToken(token), + LivechatInquiry.removeByVisitorToken(token), + ]); + } + + async deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) { + const deleteAllowed = settings.get('Message_AllowDeleting'); + const editOwn = message.u && message.u._id === guest._id; + + if (!deleteAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + await deleteMessage(message, guest as unknown as IUser); + + return true; + } + + async setUserStatusLivechatIf(userId: string, status: ILivechatAgentStatus, condition?: Filter, fields?: AKeyOf) { + const user = await Users.setLivechatStatusIf(userId, status, condition, fields); + callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); + return user; + } + + async returnRoomAsInquiry(room: IOmnichannelRoom, departmentId?: string, overrideTransferData: any = {}) { + this.logger.debug({ msg: `Transfering room to ${departmentId ? 'department' : ''} queue`, room }); + if (!room.open) { + throw new Meteor.Error('room-closed'); + } + + if (room.onHold) { + throw new Meteor.Error('error-room-onHold'); + } + + if (!room.servedBy) { + return false; + } + + const user = await Users.findOneById(room.servedBy._id); + if (!user?._id) { + throw new Meteor.Error('error-invalid-user'); + } + + // find inquiry corresponding to room + const inquiry = await LivechatInquiry.findOne({ rid: room._id }); + if (!inquiry) { + return false; + } + + const transferredBy = normalizeTransferredByData(user, room); + this.logger.debug(`Transfering room ${room._id} by user ${transferredBy._id}`); + const transferData = { roomId: room._id, scope: 'queue', departmentId, transferredBy, ...overrideTransferData }; + try { + await this.saveTransferHistory(room, transferData); + await RoutingManager.unassignAgent(inquiry, departmentId); + } catch (e) { + this.logger.error(e); + throw new Meteor.Error('error-returning-inquiry'); + } + + callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); + + return true; + } + + async saveTransferHistory(room: IOmnichannelRoom, transferData: TransferData) { + const { departmentId: previousDepartment } = room; + const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; + + check( + transferredBy, + Match.ObjectIncluding({ + _id: String, + username: String, + name: Match.Maybe(String), + type: String, + }), + ); + + const { _id, username } = transferredBy; + const scopeData = scope || (nextDepartment ? 'department' : 'agent'); + this.logger.info(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); + + const transfer = { + transferData: { + transferredBy, + ts: new Date(), + scope: scopeData, + comment, + ...(previousDepartment && { previousDepartment }), + ...(nextDepartment && { nextDepartment }), + ...(transferredTo && { transferredTo }), + }, + }; + + const type = 'livechat_transfer_history'; + const transferMessage = { + t: type, + rid: room._id, + ts: new Date(), + msg: '', + u: { + _id, + username, + }, + groupable: false, + }; + + Object.assign(transferMessage, transfer); + + await sendMessage(transferredBy, transferMessage, room); + } } export const Livechat = new LivechatClass(); diff --git a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts index 57a2b0afa3d5..0c12d0df5275 100644 --- a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts @@ -4,7 +4,7 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -33,6 +33,6 @@ Meteor.methods({ throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnAsInquiry' }); } - return Livechat.returnRoomAsInquiry(rid, departmentId); + return Livechat.returnRoomAsInquiry(room, departmentId); }, }); diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index c7d412ea4a06..516a9bc5081f 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -1,36 +1,12 @@ import { OmnichannelSourceType } from '@rocket.chat/core-typings'; -import type { MessageAttachment } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; -import { Livechat } from '../lib/Livechat'; - -interface ILivechatMessage { - token: string; - _id: string; - rid: string; - msg: string; - file?: { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - }; - files?: { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - }[]; - attachments?: MessageAttachment[]; -} +import { Livechat } from '../lib/LivechatTyped'; +import type { ILivechatMessage } from '../lib/LivechatTyped'; interface ILivechatMessageAgent { agentId: string; diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index f9fce509e39a..c8487f742b3a 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -1,5 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -13,6 +13,7 @@ import { settings } from '../../settings/server'; import { businessHourManager } from './business-hour'; import { createDefaultBusinessHourIfNotExists } from './business-hour/Helper'; import { Livechat } from './lib/Livechat'; +import { Livechat as LivechatTyped } from './lib/LivechatTyped'; import { RoutingManager } from './lib/RoutingManager'; import { LivechatAgentActivityMonitor } from './statistics/LivechatAgentActivityMonitor'; import './roomAccessValidator.internalService'; @@ -79,6 +80,11 @@ Meteor.startup(async () => { ({ user }: { user: IUser }) => user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && - void Livechat.setUserStatusLivechatIf(user._id, 'not-available', {}, { livechatStatusSystemModified: true }).catch(), + void LivechatTyped.setUserStatusLivechatIf( + user._id, + ILivechatAgentStatus.NOT_AVAILABLE, + {}, + { livechatStatusSystemModified: true }, + ).catch(), ); }); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts index 9d4590836ac9..68044a550277 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts @@ -5,8 +5,8 @@ import { LivechatRooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { MongoInternals } from 'meteor/mongo'; -import { Livechat } from '../../../../../app/livechat/server'; import { forwardRoomToAgent } from '../../../../../app/livechat/server/lib/Helper'; +import { Livechat as LivechatTyped } from '../../../../../app/livechat/server/lib/LivechatTyped'; import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; import { settings } from '../../../../../app/settings/server'; import { schedulerLogger } from './logger'; @@ -90,7 +90,7 @@ class AutoTransferChatSchedulerClass { if (!RoutingManager.getConfig()?.autoAssignAgent) { this.logger.debug(`Auto-assign agent is disabled, returning room ${roomId} as inquiry`); - await Livechat.returnRoomAsInquiry(room._id, departmentId, { + await LivechatTyped.returnRoomAsInquiry(room, departmentId, { scope: 'autoTransferUnansweredChatsToQueue', comment: timeoutDuration, transferredBy: await this.getSchedulerUser(), diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 939d91661650..ebdd9cdcac01 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -12,7 +12,6 @@ import type { ParsedMail, Attachment } from 'mailparser'; import stripHtml from 'string-strip-html'; import { FileUpload } from '../../../app/file-upload/server'; -import { Livechat } from '../../../app/livechat/server/lib/Livechat'; import { Livechat as LivechatTyped } from '../../../app/livechat/server/lib/LivechatTyped'; import { QueueManager } from '../../../app/livechat/server/lib/QueueManager'; import { settings } from '../../../app/settings/server'; @@ -148,7 +147,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme const rid = room?._id ?? Random.id(); const msgId = Random.id(); - Livechat.sendMessage({ + LivechatTyped.sendMessage({ guest, message: { _id: msgId, From a3b3dea4816c6a93829d5be3e56c9b68fbd9ad48 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 19 Oct 2023 18:27:43 -0700 Subject: [PATCH 07/22] regression: validateLicenseLimits not using the expected limit (#30693) --- ee/packages/license/src/license.spec.ts | 3 ++- ee/packages/license/src/license.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index b637ee33ddfd..c605d6467118 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -41,7 +41,7 @@ it('should prevent if the counter is equal or over the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); -it.skip('should not prevent an action if another limit is over the limit', async () => { +it('should not prevent an action if another limit is over the limit', async () => { const licenseManager = await getReadyLicenseManager(); const license = await new MockedLicenseBuilder() @@ -63,6 +63,7 @@ it.skip('should not prevent an action if another limit is over the limit', async licenseManager.setLicenseLimitCounter('activeUsers', () => 11); licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2); await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); describe('Validate License Limits', () => { diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 14dceedd735a..8212a4a0da27 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -268,6 +268,7 @@ export class LicenseManager extends Emitter { ...(extraCount && { behaviors: ['prevent_action'] }), isNewLicense: false, suppressLog: !!suppressLog, + limits: [action], context: { [action]: { extraCount, From c29f5ff4172845d8c8880fd2e93fb5a16e7c8337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Thu, 19 Oct 2023 23:01:44 -0300 Subject: [PATCH 08/22] chore: bump fuselage packages (#30696) --- .../admin/info/UsagePieGraph.stories.tsx | 10 +-- .../views/room/Header/icons/Encrypted.tsx | 2 +- .../users/ActiveUsersSection.tsx | 6 +- .../users/UsersByTimeOfTheDaySection.tsx | 14 ++-- .../ee/client/views/admin/info/SeatsCard.tsx | 2 +- apps/meteor/package.json | 6 +- ee/packages/pdf-worker/package.json | 2 +- ee/packages/ui-theming/package.json | 2 +- packages/fuselage-ui-kit/package.json | 2 +- packages/gazzodown/package.json | 4 +- packages/livechat/package.json | 4 +- packages/ui-client/package.json | 2 +- packages/ui-composer/package.json | 2 +- packages/ui-video-conf/package.json | 2 +- packages/uikit-playground/package.json | 6 +- yarn.lock | 65 +++++++++---------- 16 files changed, 62 insertions(+), 69 deletions(-) diff --git a/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx b/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx index 12f211d47c5e..d76731f0d2c4 100644 --- a/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx +++ b/apps/meteor/client/views/admin/info/UsagePieGraph.stories.tsx @@ -38,27 +38,27 @@ export const Animated: Story, 'size' | { total: 100, used: 0, - color: colorTokens.s500, + color: colorTokens.g500, }, { total: 100, used: 25, - color: colorTokens.p500, + color: colorTokens.b500, }, { total: 100, used: 50, - color: colorTokens.w500, + color: colorTokens.y500, }, { total: 100, used: 75, - color: colorTokens['s1-500'], + color: colorTokens.o500, }, { total: 100, used: 100, - color: colorTokens.d500, + color: colorTokens.r500, }, ]); diff --git a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx index dbfda21f5b7a..bd380c5d8af2 100644 --- a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx +++ b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx @@ -29,7 +29,7 @@ const Encrypted = ({ room }: { room: IRoom }) => { }); }); return e2eEnabled && room?.encrypted ? ( - + ) : null; }; diff --git a/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx b/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx index e067f777090f..eb504033e1e6 100644 --- a/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx +++ b/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx @@ -127,7 +127,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement variation: diffDailyActiveUsers ?? 0, description: ( <> - {t('Daily_Active_Users')} + {t('Daily_Active_Users')} ), }, @@ -136,7 +136,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement variation: diffWeeklyActiveUsers ?? 0, description: ( <> - {t('Weekly_Active_Users')} + {t('Weekly_Active_Users')} ), }, @@ -203,7 +203,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement right: 0, left: 40, }} - colors={[colors.p200, colors.p300, colors.p500]} + colors={[colors.b200, colors.b300, colors.b500]} axisLeft={{ // TODO: Get it from theme tickSize: 0, diff --git a/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx b/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx index d8f13bb891a3..fa5664ebca27 100644 --- a/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx +++ b/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx @@ -119,13 +119,13 @@ const UsersByTimeOfTheDaySection = ({ timezone }: UsersByTimeOfTheDaySectionProp type: 'quantize', colors: [ // TODO: Get it from theme - colors.p100, - colors.p200, - colors.p300, - colors.p400, - colors.p500, - colors.p600, - colors.p700, + colors.b100, + colors.b200, + colors.b300, + colors.b400, + colors.b500, + colors.b600, + colors.b700, ], }} emptyColor='transparent' diff --git a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx index b595dd9c1fae..804893ae8458 100644 --- a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx +++ b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx @@ -23,7 +23,7 @@ const SeatsCard = ({ seatsCap }: SeatsCardProps): ReactElement => { const isNearLimit = seatsCap && seatsCap.activeUsers / seatsCap.maxActiveUsers >= 0.8; - const color = isNearLimit ? colors.d500 : undefined; + const color = isNearLimit ? colors.r500 : undefined; return ( diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 3ee3366f47dd..18e4725ffc40 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -236,11 +236,11 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.1", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.2", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", "@rocket.chat/fuselage-toastbar": "next", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/gazzodown": "workspace:^", "@rocket.chat/i18n": "workspace:^", @@ -251,7 +251,7 @@ "@rocket.chat/license": "workspace:^", "@rocket.chat/log-format": "workspace:^", "@rocket.chat/logger": "workspace:^", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/logo": "^0.31.28", "@rocket.chat/memo": "next", "@rocket.chat/message-parser": "next", "@rocket.chat/model-typings": "workspace:^", diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index 9081c64fba34..f4bd7c5b44f6 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -34,7 +34,7 @@ "dependencies": { "@react-pdf/renderer": "^3.1.12", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@types/react": "~17.0.62", "emoji-assets": "^7.0.1", "emoji-toolkit": "^7.0.1", diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index 11aa5fd57ff8..52b8062f332d 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 4555216c2d44..a32d2456752b 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -56,7 +56,7 @@ "devDependencies": { "@rocket.chat/apps-engine": "1.41.0-alpha.290", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", "@rocket.chat/icons": "^0.32.0", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index e891e5677c75..311950f2222d 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -6,8 +6,8 @@ "@babel/core": "~7.22.9", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage": "^0.35.0", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/message-parser": "next", "@rocket.chat/styled": "next", "@rocket.chat/ui-client": "workspace:^", diff --git a/packages/livechat/package.json b/packages/livechat/package.json index 756248c1df0c..bb92a0716669 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -30,8 +30,8 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/ddp-client": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage-tokens": "next", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/fuselage-tokens": "^0.32.0", + "@rocket.chat/logo": "^0.31.28", "@storybook/addon-essentials": "~6.5.16", "@storybook/addon-postcss": "~2.0.0", "@storybook/preact": "~6.5.16", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index f8ef2d3a1e93..7edd02cc6e56 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@babel/core": "~7.22.9", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/mock-providers": "workspace:^", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index b5c804b4a2ad..b13328bd001b 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@babel/core": "~7.22.9", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/icons": "^0.32.0", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index 7ca7b1d86140..2e880b3db8bb 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.9", "@rocket.chat/css-in-js": "next", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/styled": "next", diff --git a/packages/uikit-playground/package.json b/packages/uikit-playground/package.json index 5a8b1276defb..d9abdf001162 100644 --- a/packages/uikit-playground/package.json +++ b/packages/uikit-playground/package.json @@ -15,13 +15,13 @@ "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.34.0", + "@rocket.chat/fuselage": "^0.35.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/fuselage-ui-kit": "workspace:~", "@rocket.chat/icons": "^0.32.0", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/logo": "^0.31.28", "@rocket.chat/styled": "next", "@rocket.chat/ui-contexts": "workspace:~", "codemirror": "^6.0.1", diff --git a/yarn.lock b/yarn.lock index 4b4fd7f27bc9..dc01a3898eba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8219,17 +8219,10 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/fuselage-tokens@npm:^0.31.25": - version: 0.31.25 - resolution: "@rocket.chat/fuselage-tokens@npm:0.31.25" - checksum: d05460f2f7b7f01b1498aab6fb7d932b7d752d55ce5a6bad6e7a42f2c1f056164ff8caa7dd8ec11bc0f4441a83d8aad0b8aab5e02c03f3452c4583d159b1a2f7 - languageName: node - linkType: hard - -"@rocket.chat/fuselage-tokens@npm:next": - version: 0.32.0-dev.379 - resolution: "@rocket.chat/fuselage-tokens@npm:0.32.0-dev.379" - checksum: c5cf40295c4ae1a5918651b9e156629d6400d5823b8cf5f81a14c66da986a9302d79392b45e991c2fc37aad9633f3d8e2f7f29c68969592340b05968265244e6 +"@rocket.chat/fuselage-tokens@npm:^0.32.0": + version: 0.32.0 + resolution: "@rocket.chat/fuselage-tokens@npm:0.32.0" + checksum: 8da7836877ba93462f90d13de6d3d3add8b2758b58c7988e14a8f0deffd1ceef0547f26d4c60a7ddc881e21e3327b5a04cbf17336e5ca8ab9c19789d8e6af3c0 languageName: node linkType: hard @@ -8239,7 +8232,7 @@ __metadata: dependencies: "@rocket.chat/apps-engine": 1.41.0-alpha.290 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/fuselage-polyfills": next "@rocket.chat/gazzodown": "workspace:^" @@ -8288,13 +8281,13 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.34.0": - version: 0.34.0 - resolution: "@rocket.chat/fuselage@npm:0.34.0" +"@rocket.chat/fuselage@npm:^0.35.0": + version: 0.35.0 + resolution: "@rocket.chat/fuselage@npm:0.35.0" dependencies: "@rocket.chat/css-in-js": ^0.31.25 "@rocket.chat/css-supports": ^0.31.25 - "@rocket.chat/fuselage-tokens": ^0.31.25 + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/memo": ^0.31.25 "@rocket.chat/styled": ^0.31.25 invariant: ^2.2.4 @@ -8308,7 +8301,7 @@ __metadata: react: ^17.0.2 react-dom: ^17.0.2 react-virtuoso: 1.2.4 - checksum: 72cd1dd7ef13cc3b69fadac5c064a45cd2b65b8a221cde2e8149fa873ac6de89648c677caedb10979e5cf08d39b79f1d7a30caa6378bdeeb873414c7fbac5e6e + checksum: 46deea587a1ab4c80a25f4e93882905e2f24778c0e612b7cdd18bfb0c72b2c079d4eee6fe7ad4c52a62354197ebed0a62eaf939b5714859b7086c923668f3f05 languageName: node linkType: hard @@ -8319,8 +8312,8 @@ __metadata: "@babel/core": ~7.22.9 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage": ^0.35.0 + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/message-parser": next "@rocket.chat/styled": next "@rocket.chat/ui-client": "workspace:^" @@ -8468,9 +8461,9 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/gazzodown": "workspace:^" - "@rocket.chat/logo": ^0.31.27 + "@rocket.chat/logo": ^0.31.28 "@rocket.chat/message-parser": next "@rocket.chat/random": "workspace:~" "@rocket.chat/sdk": ^1.0.0-alpha.42 @@ -8581,16 +8574,16 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/logo@npm:^0.31.27": - version: 0.31.27 - resolution: "@rocket.chat/logo@npm:0.31.27" +"@rocket.chat/logo@npm:^0.31.28": + version: 0.31.28 + resolution: "@rocket.chat/logo@npm:0.31.28" dependencies: "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/styled": ^0.31.25 peerDependencies: react: 17.0.2 react-dom: 17.0.2 - checksum: acc56410813a0d4f634f9e847bc4b49275c26aff4e2f285720818cb012a2ad42554982fcc4078c485222a9c9a78244d1a4b16b60588b5c50441b8928c3957efb + checksum: 2ba185326fadb0d1ccf7d2767435204dd3cd857400d18e59eb8a07055ac0183c6e780d0e8e45436410c551aef516ecea7491a5c87b59406252b2be4694034af8 languageName: node linkType: hard @@ -8665,11 +8658,11 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.1 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.2 - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/fuselage-polyfills": next "@rocket.chat/fuselage-toastbar": next - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/fuselage-ui-kit": "workspace:^" "@rocket.chat/gazzodown": "workspace:^" "@rocket.chat/i18n": "workspace:^" @@ -8681,7 +8674,7 @@ __metadata: "@rocket.chat/livechat": "workspace:^" "@rocket.chat/log-format": "workspace:^" "@rocket.chat/logger": "workspace:^" - "@rocket.chat/logo": ^0.31.27 + "@rocket.chat/logo": ^0.31.28 "@rocket.chat/memo": next "@rocket.chat/message-parser": next "@rocket.chat/mock-providers": "workspace:^" @@ -9175,7 +9168,7 @@ __metadata: dependencies: "@react-pdf/renderer": ^3.1.12 "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@storybook/addon-essentials": ~6.5.16 "@storybook/react": ~6.5.16 "@testing-library/jest-dom": ^5.16.5 @@ -9528,7 +9521,7 @@ __metadata: dependencies: "@babel/core": ~7.22.9 "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/icons": ^0.32.0 "@rocket.chat/mock-providers": "workspace:^" @@ -9579,7 +9572,7 @@ __metadata: dependencies: "@babel/core": ~7.22.9 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/icons": ^0.32.0 "@storybook/addon-actions": ~6.5.16 "@storybook/addon-docs": ~6.5.16 @@ -9650,7 +9643,7 @@ __metadata: resolution: "@rocket.chat/ui-theming@workspace:ee/packages/ui-theming" dependencies: "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/icons": ^0.32.0 "@rocket.chat/ui-contexts": "workspace:~" @@ -9693,7 +9686,7 @@ __metadata: "@rocket.chat/css-in-js": next "@rocket.chat/emitter": next "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/icons": ^0.32.0 "@rocket.chat/styled": next @@ -9736,13 +9729,13 @@ __metadata: "@codemirror/tooltip": ^0.19.16 "@lezer/highlight": ^1.1.6 "@rocket.chat/css-in-js": next - "@rocket.chat/fuselage": ^0.34.0 + "@rocket.chat/fuselage": ^0.35.0 "@rocket.chat/fuselage-hooks": ^0.32.1 "@rocket.chat/fuselage-polyfills": next - "@rocket.chat/fuselage-tokens": next + "@rocket.chat/fuselage-tokens": ^0.32.0 "@rocket.chat/fuselage-ui-kit": "workspace:~" "@rocket.chat/icons": ^0.32.0 - "@rocket.chat/logo": ^0.31.27 + "@rocket.chat/logo": ^0.31.28 "@rocket.chat/styled": next "@rocket.chat/ui-contexts": "workspace:~" "@types/react": ~17.0.62 From febc7165dc62a37599acde295cd1e61d8f9c8654 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 05:59:05 -0700 Subject: [PATCH 09/22] test: License v3 - add test cases for empty limitations (#30695) --- ee/packages/license/src/license.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index c605d6467118..73cf13d75035 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -22,6 +22,19 @@ it('should not prevent if the counter is under the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); }); +it('should not prevent actions if there is no limit set in the license', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder(); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); +}); + it('should prevent if the counter is equal or over the limit', async () => { const licenseManager = await getReadyLicenseManager(); From 832df7f4cd1fd67e5552cf38af272d27c99820dc Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 06:22:34 -0700 Subject: [PATCH 10/22] feat: License v3 store prevent action results and just fire it if changes (#30692) --- ee/packages/license/__tests__/emitter.spec.ts | 176 ++++++++++++++++++ .../license/src/definition/LicenseBehavior.ts | 8 +- .../license/src/definition/LicenseInfo.ts | 1 + ee/packages/license/src/definition/events.ts | 6 + ee/packages/license/src/events/emitter.ts | 15 +- ee/packages/license/src/events/listeners.ts | 8 + ee/packages/license/src/index.ts | 3 + ee/packages/license/src/license.ts | 38 +++- 8 files changed, 249 insertions(+), 6 deletions(-) diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 6147d12623bc..5682715d6d2b 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -116,4 +116,180 @@ describe('Event License behaviors', () => { await expect(fn).toBeCalledWith(undefined); }); }); + + /** + * this is only called when the prevent_action behavior is triggered for the first time + * it will not be called again until the behavior is toggled + */ + describe('Toggled behaviors', () => { + it('should emit `behaviorToggled:prevent_action` event when the limit is reached once but `behavior:prevent_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', fn); + + licenseManager.onBehaviorToggled('prevent_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:allow_action` event when the limit is not reached once but `behavior:allow_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + + licenseManager.onBehaviorToggled('allow_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:prevent_action` and `behaviorToggled:allow_action` events when the shouldPreventAction function changes the result', async () => { + const licenseManager = await getReadyLicenseManager(); + const preventFn = jest.fn(); + const preventToggleFn = jest.fn(); + const allowFn = jest.fn(); + const allowToggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + licenseManager.onBehaviorToggled('prevent_action', preventToggleFn); + licenseManager.onBehaviorTriggered('allow_action', allowFn); + licenseManager.onBehaviorToggled('allow_action', allowToggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(1); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + }); + }); + + describe('Allow actions', () => { + it('should emit `behavior:allow_action` event when the limit is not reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const preventFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(1); + await expect(preventFn).toBeCalledTimes(0); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + }); }); diff --git a/ee/packages/license/src/definition/LicenseBehavior.ts b/ee/packages/license/src/definition/LicenseBehavior.ts index 8b5af5f3c481..ac2249233ab5 100644 --- a/ee/packages/license/src/definition/LicenseBehavior.ts +++ b/ee/packages/license/src/definition/LicenseBehavior.ts @@ -1,7 +1,13 @@ import type { LicenseLimitKind } from './ILicenseV3'; import type { LicenseModule } from './LicenseModule'; -export type LicenseBehavior = 'invalidate_license' | 'start_fair_policy' | 'prevent_action' | 'prevent_installation' | 'disable_modules'; +export type LicenseBehavior = + | 'invalidate_license' + | 'start_fair_policy' + | 'prevent_action' + | 'allow_action' + | 'prevent_installation' + | 'disable_modules'; export type BehaviorWithContext = | { diff --git a/ee/packages/license/src/definition/LicenseInfo.ts b/ee/packages/license/src/definition/LicenseInfo.ts index 4c4e34d30528..019d1b9e1ca0 100644 --- a/ee/packages/license/src/definition/LicenseInfo.ts +++ b/ee/packages/license/src/definition/LicenseInfo.ts @@ -5,6 +5,7 @@ import type { LicenseModule } from './LicenseModule'; export type LicenseInfo = { license?: ILicenseV3; activeModules: LicenseModule[]; + preventedActions: Record; limits: Record; tags: ILicenseTag[]; trial: boolean; diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts index 53f3afe846db..ad1114738cce 100644 --- a/ee/packages/license/src/definition/events.ts +++ b/ee/packages/license/src/definition/events.ts @@ -4,9 +4,15 @@ import type { LicenseModule } from './LicenseModule'; type ModuleValidation = Record<`${'invalid' | 'valid'}:${LicenseModule}`, undefined>; type BehaviorTriggered = Record<`behavior:${LicenseBehavior}`, { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }>; +type BehaviorTriggeredToggled = Record< + `behaviorToggled:${LicenseBehavior}`, + { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind } +>; + type LimitReached = Record<`limitReached:${LicenseLimitKind}`, undefined>; export type LicenseEvents = ModuleValidation & + BehaviorTriggeredToggled & BehaviorTriggered & LimitReached & { validate: undefined; diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9256bcafe5f7..51f3282a9742 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -33,7 +33,7 @@ export function behaviorTriggered(this: LicenseManager, options: BehaviorWithCon logger.error({ msg: 'Error running behavior triggered event', error }); } - if (behavior !== 'prevent_action') { + if (!['prevent_action'].includes(behavior)) { return; } @@ -48,6 +48,19 @@ export function behaviorTriggered(this: LicenseManager, options: BehaviorWithCon } } +export function behaviorTriggeredToggled(this: LicenseManager, options: BehaviorWithContext) { + const { behavior, reason, modules: _, ...rest } = options; + + try { + this.emit(`behaviorToggled:${behavior}`, { + reason, + ...rest, + }); + } catch (error) { + logger.error({ msg: 'Error running behavior triggered event', error }); + } +} + export function licenseValidated(this: LicenseManager) { try { this.emit('validate'); diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index ecabecb28c0f..6c80867b7ac8 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -79,6 +79,14 @@ export function onBehaviorTriggered( this.on(`behavior:${behavior}`, cb); } +export function onBehaviorToggled( + this: LicenseManager, + behavior: Exclude, + cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void, +) { + this.on(`behaviorToggled:${behavior}`, cb); +} + export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: () => void) { this.on(`limitReached:${limitKind}`, cb); } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 9707a41d96ab..92b30c4af40d 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -4,6 +4,7 @@ import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; import { onLicense } from './events/deprecated'; import { + onBehaviorToggled, onBehaviorTriggered, onInvalidFeature, onInvalidateLicense, @@ -97,6 +98,8 @@ export class LicenseImp extends LicenseManager implements License { onBehaviorTriggered = onBehaviorTriggered; + onBehaviorToggled = onBehaviorToggled; + // Deprecated: onLicense = onLicense; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 8212a4a0da27..5987065bd697 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -12,7 +12,7 @@ import type { LicenseEvents } from './definition/events'; import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; -import { behaviorTriggered, licenseInvalidated, licenseValidated } from './events/emitter'; +import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter'; import { logger } from './logger'; import { getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; @@ -49,6 +49,8 @@ export class LicenseManager extends Emitter { private _lockedLicense: string | undefined; + public shouldPreventActionResults = new Map(); + constructor() { super(); @@ -106,6 +108,8 @@ export class LicenseManager extends Emitter { this._unmodifiedLicense = undefined; this._valid = false; this._lockedLicense = undefined; + + this.shouldPreventActionResults.clear(); clearPendingLicense.call(this); } @@ -243,6 +247,12 @@ export class LicenseManager extends Emitter { } } + private triggerBehaviorEventsToggled(validationResult: BehaviorWithContext[]): void { + for (const { ...options } of validationResult) { + behaviorTriggeredToggled.call(this, { ...options }); + } + } + public hasValidLicense(): boolean { return Boolean(this.getLicense()); } @@ -279,18 +289,37 @@ export class LicenseManager extends Emitter { const validationResult = await runValidation.call(this, license, options); + const shouldPreventAction = isBehaviorsInResult(validationResult, ['prevent_action']); + // extra values should not call events since they are not actually reaching the limit just checking if they would if (extraCount) { - return isBehaviorsInResult(validationResult, ['prevent_action']); + return shouldPreventAction; } if (isBehaviorsInResult(validationResult, ['invalidate_license', 'disable_modules', 'start_fair_policy'])) { await this.revalidateLicense(); } - this.triggerBehaviorEvents(filterBehaviorsResult(validationResult, ['prevent_action'])); + const eventsToEmit = shouldPreventAction + ? filterBehaviorsResult(validationResult, ['prevent_action']) + : [ + { + behavior: 'allow_action', + modules: [], + reason: 'limit', + limit: action, + } as BehaviorWithContext, + ]; + + if (this.shouldPreventActionResults.get(action) !== shouldPreventAction) { + this.shouldPreventActionResults.set(action, shouldPreventAction); + + this.triggerBehaviorEventsToggled(eventsToEmit); + } + + this.triggerBehaviorEvents(eventsToEmit); - return isBehaviorsInResult(validationResult, ['prevent_action']); + return shouldPreventAction; } public async getInfo({ @@ -331,6 +360,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, + preventedActions: Object.fromEntries(this.shouldPreventActionResults.entries()) as Record, limits: limits as Record, tags: license?.information.tags || [], trial: Boolean(license?.information.trial), From 94c6e897264ba54ae52a0f187b6d6e05f643d977 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 06:31:56 -0700 Subject: [PATCH 11/22] chore: replace endpoint to licenses.info (#30697) --- apps/meteor/client/hooks/useLicense.ts | 6 +- .../hooks/useAdministrationItems.spec.tsx | 47 +++++++------- .../client/views/admin/info/LicenseCard.tsx | 63 +++++++++++-------- .../client/views/hooks/useUpgradeTabParams.ts | 10 +-- apps/meteor/ee/server/api/licenses.ts | 12 ++-- .../tests/end-to-end/api/20-licenses.js | 12 ++-- packages/rest-typings/src/v1/licenses.ts | 2 +- 7 files changed, 80 insertions(+), 72 deletions(-) diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 0f568d9bd5cc..1549b431eeb7 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -3,8 +3,8 @@ import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; -export const useLicense = (): UseQueryResult> => { - const getLicenses = useEndpoint('GET', '/v1/licenses.get'); +export const useLicense = (): UseQueryResult> => { + const getLicenses = useEndpoint('GET', '/v1/licenses.info'); const canViewLicense = usePermission('view-privileged-setting'); return useQuery( @@ -13,7 +13,7 @@ export const useLicense = (): UseQueryResult { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: ['testModule'], - meta: { trial: false }, - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + // @ts-expect-error this is a mock + license: { activeModules: ['testModule'] }, + trial: false, + }, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -25,19 +24,21 @@ it('should not show upgrade item if has license and not have trial', async () => }); await waitFor(() => !!(result.all.length > 1)); - expect(result.current.length).toEqual(1); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'workspace', + }), + ); }); it('should return an upgrade item if not have license or if have a trial', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -62,12 +63,9 @@ it('should return an upgrade item if not have license or if have a trial', async it('should return omnichannel item if has `view-livechat-manager` permission ', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -90,12 +88,9 @@ it('should return omnichannel item if has `view-livechat-manager` permission ', it('should show administration item if has at least one admin permission', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { diff --git a/apps/meteor/client/views/admin/info/LicenseCard.tsx b/apps/meteor/client/views/admin/info/LicenseCard.tsx index bccbddaa6db7..8aab636f4720 100644 --- a/apps/meteor/client/views/admin/info/LicenseCard.tsx +++ b/apps/meteor/client/views/admin/info/LicenseCard.tsx @@ -19,15 +19,7 @@ const LicenseCard = (): ReactElement => { const isAirGapped = true; - const { data, isError, isLoading } = useLicense(); - - const { modules = [] } = isLoading || isError || !data?.licenses?.length ? {} : data?.licenses[0]; - - const hasEngagement = modules.includes('engagement-dashboard'); - const hasOmnichannel = modules.includes('livechat-enterprise'); - const hasAuditing = modules.includes('auditing'); - const hasCannedResponses = modules.includes('canned-responses'); - const hasReadReceipts = modules.includes('message-read-receipt'); + const request = useLicense(); const handleApplyLicense = useMutableCallback(() => setModal( @@ -41,6 +33,37 @@ const LicenseCard = (): ReactElement => { ), ); + if (request.isLoading || request.isError) { + return ( + + {t('License')} + + + + + + + {t('Features')} + + + + + + + + + + ); + } + + const { activeModules } = request.data.license; + + const hasEngagement = activeModules.includes('engagement-dashboard'); + const hasOmnichannel = activeModules.includes('livechat-enterprise'); + const hasAuditing = activeModules.includes('auditing'); + const hasCannedResponses = activeModules.includes('canned-responses'); + const hasReadReceipts = activeModules.includes('message-read-receipt'); + return ( {t('License')} @@ -51,22 +74,12 @@ const LicenseCard = (): ReactElement => { {t('Features')} - {isLoading ? ( - <> - - - - - - ) : ( - <> - - - - - - - )} + + + + + + diff --git a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts index 65dd4cb1e396..1d152b08d5b9 100644 --- a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts +++ b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts @@ -1,4 +1,3 @@ -import type { ILicenseV2, ILicenseV3 } from '@rocket.chat/license'; import { useSetting } from '@rocket.chat/ui-contexts'; import { format } from 'date-fns'; @@ -14,14 +13,11 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const { data: registrationStatusData, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); const registered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; - const hasValidLicense = licensesData?.licenses.some((license) => license.modules.length > 0) ?? false; + const hasValidLicense = Boolean(licensesData?.license?.license ?? false); const hadExpiredTrials = cloudWorkspaceHadTrial ?? false; - const licenses = (licensesData?.licenses || []) as (Partial & { modules: string[] })[]; - - const trialLicense = licenses.find(({ meta, information }) => information?.trial ?? meta?.trial); - const isTrial = Boolean(trialLicense); - const trialEndDateStr = trialLicense?.information?.visualExpiration || trialLicense?.meta?.trialEnd || trialLicense?.cloudMeta?.trialEnd; + const isTrial = Boolean(licensesData?.license?.trial); + const trialEndDateStr = licensesData?.license?.license?.information?.visualExpiration; const trialEndDate = trialEndDateStr ? format(new Date(trialEndDateStr), 'yyyy-MM-dd') : undefined; const upgradeTabType = getUpgradeTabType({ diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index b7ac3ba81e9c..a2e7a75b072a 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -5,12 +5,15 @@ import { check } from 'meteor/check'; import { API } from '../../../app/api/server/api'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; +import { apiDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; API.v1.addRoute( 'licenses.get', { authRequired: true }, { async get() { + apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); + if (!(await hasPermissionAsync(this.userId, 'view-privileged-setting'))) { return API.v1.unauthorized(); } @@ -31,9 +34,9 @@ API.v1.addRoute( const unrestrictedAccess = await hasPermissionAsync(this.userId, 'view-privileged-setting'); const loadCurrentValues = unrestrictedAccess && Boolean(this.queryParams.loadValues); - const data = await License.getInfo({ limits: unrestrictedAccess, license: unrestrictedAccess, currentValues: loadCurrentValues }); + const license = await License.getInfo({ limits: unrestrictedAccess, license: unrestrictedAccess, currentValues: loadCurrentValues }); - return API.v1.success({ data }); + return API.v1.success({ license }); }, }, ); @@ -81,8 +84,9 @@ API.v1.addRoute( { authOrAnonRequired: true }, { get() { - const isEnterpriseEdtion = License.hasValidLicense(); - return API.v1.success({ isEnterprise: isEnterpriseEdtion }); + apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); + const isEnterpriseEdition = License.hasValidLicense(); + return API.v1.success({ isEnterprise: isEnterpriseEdition }); }, }, ); diff --git a/apps/meteor/tests/end-to-end/api/20-licenses.js b/apps/meteor/tests/end-to-end/api/20-licenses.js index 302011addef9..9088e4e9e1d9 100644 --- a/apps/meteor/tests/end-to-end/api/20-licenses.js +++ b/apps/meteor/tests/end-to-end/api/20-licenses.js @@ -126,9 +126,9 @@ describe('licenses', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('data').and.to.be.an('object'); - expect(res.body.data).to.not.have.property('license'); - expect(res.body.data).to.have.property('tags').and.to.be.an('array'); + expect(res.body).to.have.property('license').and.to.be.an('object'); + expect(res.body.license).to.not.have.property('license'); + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); }) .end(done); }); @@ -140,11 +140,11 @@ describe('licenses', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body).to.have.property('license').and.to.be.an('object'); if (process.env.IS_EE) { - expect(res.body.data).to.have.property('license').and.to.be.an('object'); + expect(res.body.license).to.have.property('license').and.to.be.an('object'); } - expect(res.body.data).to.have.property('tags').and.to.be.an('array'); + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); }) .end(done); diff --git a/packages/rest-typings/src/v1/licenses.ts b/packages/rest-typings/src/v1/licenses.ts index d229ca49f1fc..4eb1ac196840 100644 --- a/packages/rest-typings/src/v1/licenses.ts +++ b/packages/rest-typings/src/v1/licenses.ts @@ -45,7 +45,7 @@ export type LicensesEndpoints = { }; '/v1/licenses.info': { GET: (params: licensesInfoProps) => { - data: LicenseInfo; + license: LicenseInfo; }; }; '/v1/licenses.add': { From daa28f02a404599243a734c7cf8a1d2a0ff6c96b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 20 Oct 2023 14:31:54 -0300 Subject: [PATCH 12/22] test: fix API test flakiness (#30657) --- .../rocketchat-mongo-config/server/index.js | 19 +++++++++---------- .../server/lib/dataExport/uploadZipFile.ts | 6 ++---- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 80e664a9c209..65464a31095c 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -34,20 +34,19 @@ if (Object.keys(mongoConnectionOptions).length > 0) { process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; -// Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured -if (process.env.NODE_ENV !== 'development') { - const { sendAsync } = Email; +// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null +if (process.env.TEST_MODE === 'true') { + Email.sendAsync = function _sendAsync(options) { + console.log('Email.sendAsync', options); + }; +} else if (process.env.NODE_ENV !== 'development') { + // Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured const stream = new PassThrough(); stream.on('data', () => {}); stream.on('end', () => {}); - Email.sendAsync = function _sendAsync(options) { - return sendAsync.call(this, { stream, ...options }); - }; -} -// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null -if (process.env.TEST_MODE === 'true') { + const { sendAsync } = Email; Email.sendAsync = function _sendAsync(options) { - console.log('Email.sendAsync', options); + return sendAsync.call(this, { stream, ...options }); }; } diff --git a/apps/meteor/server/lib/dataExport/uploadZipFile.ts b/apps/meteor/server/lib/dataExport/uploadZipFile.ts index e6a76472db7f..5fe9ea2d57dd 100644 --- a/apps/meteor/server/lib/dataExport/uploadZipFile.ts +++ b/apps/meteor/server/lib/dataExport/uploadZipFile.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'fs'; -import { open, stat } from 'fs/promises'; +import { stat } from 'fs/promises'; import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; @@ -28,9 +28,7 @@ export const uploadZipFile = async (filePath: string, userId: IUser['_id'], expo name: newFileName, }; - const { fd } = await open(filePath); - - const stream = createReadStream('', { fd }); // @todo once upgrades to Node.js v16.x, use createReadStream from fs.promises.open + const stream = createReadStream(filePath); const userDataStore = FileUpload.getStore('UserDataFiles'); From 4fb5523406fec1dcb4509bbb7a115d49c0ca6cc4 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 11:19:07 -0700 Subject: [PATCH 13/22] feat: License v3 sync event (#30703) --- apps/meteor/client/hooks/useLicense.ts | 20 +++++- .../license/server/license.internalService.ts | 4 ++ apps/meteor/ee/app/license/server/startup.ts | 22 +++++++ .../modules/listeners/listeners.module.ts | 3 + ee/packages/ddp-client/src/types/streams.ts | 2 + ee/packages/license/__tests__/emitter.spec.ts | 64 +++++++++++++++++++ .../definition/LicenseValidationOptions.ts | 1 + ee/packages/license/src/definition/events.ts | 1 + ee/packages/license/src/events/listeners.ts | 7 ++ ee/packages/license/src/index.ts | 3 + ee/packages/license/src/license.ts | 54 +++++++++++++++- packages/core-services/src/Events.ts | 4 ++ 12 files changed, 180 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 1549b431eeb7..8ba594c5b5d3 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -1,12 +1,28 @@ +import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; +import { useEndpoint, usePermission, useSingleStream } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; export const useLicense = (): UseQueryResult> => { const getLicenses = useEndpoint('GET', '/v1/licenses.info'); const canViewLicense = usePermission('view-privileged-setting'); + const queryClient = useQueryClient(); + + const invalidate = useDebouncedCallback( + () => { + queryClient.invalidateQueries(['licenses', 'getLicenses']); + }, + 5000, + [], + ); + + const notify = useSingleStream('notify-all'); + + useEffect(() => notify('license', () => invalidate()), [notify, invalidate]); + return useQuery( ['licenses', 'getLicenses'], () => { diff --git a/apps/meteor/ee/app/license/server/license.internalService.ts b/apps/meteor/ee/app/license/server/license.internalService.ts index 9036a9b1848c..6c179bf9797e 100644 --- a/apps/meteor/ee/app/license/server/license.internalService.ts +++ b/apps/meteor/ee/app/license/server/license.internalService.ts @@ -23,6 +23,10 @@ export class LicenseService extends ServiceClassInternal implements ILicense { License.onModule((licenseModule) => { void api.broadcast('license.module', licenseModule); }); + + this.onEvent('license.actions', (preventedActions) => License.syncShouldPreventActionResults(preventedActions)); + + this.onEvent('license.sync', () => License.sync()); } async started(): Promise { diff --git a/apps/meteor/ee/app/license/server/startup.ts b/apps/meteor/ee/app/license/server/startup.ts index 8cce4d3d1410..fc6b693e0441 100644 --- a/apps/meteor/ee/app/license/server/startup.ts +++ b/apps/meteor/ee/app/license/server/startup.ts @@ -1,3 +1,5 @@ +import { api } from '@rocket.chat/core-services'; +import type { LicenseLimitKind } from '@rocket.chat/license'; import { License } from '@rocket.chat/license'; import { Subscriptions, Users, Settings } from '@rocket.chat/models'; import { wrapExceptions } from '@rocket.chat/tools'; @@ -93,6 +95,26 @@ settings.onReady(async () => { License.onBehaviorTriggered('start_fair_policy', async (context) => syncByTrigger(`start_fair_policy_${context.limit}`)); License.onBehaviorTriggered('disable_modules', async (context) => syncByTrigger(`disable_modules_${context.limit}`)); + + License.onChange(() => api.broadcast('license.sync')); + + License.onBehaviorToggled('prevent_action', (context) => { + if (!context.limit) { + return; + } + void api.broadcast('license.actions', { + [context.limit]: true, + } as Record, boolean>); + }); + + License.onBehaviorToggled('allow_action', (context) => { + if (!context.limit) { + return; + } + void api.broadcast('license.actions', { + [context.limit]: false, + } as Record, boolean>); + }); }); License.setLicenseLimitCounter('activeUsers', () => Users.getActiveLocalUserCount()); diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index f21081e43d0a..c580b47d7c6e 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -29,6 +29,9 @@ export class ListenersModule { constructor(service: IServiceClass, notifications: NotificationsModule) { const logger = new Logger('ListenersModule'); + service.onEvent('license.sync', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('license.actions', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('emoji.deleteCustom', (emoji) => { notifications.notifyLoggedInThisInstance('deleteEmojiCustom', { emojiData: emoji, diff --git a/ee/packages/ddp-client/src/types/streams.ts b/ee/packages/ddp-client/src/types/streams.ts index 9010517faf7a..abadd53c1851 100644 --- a/ee/packages/ddp-client/src/types/streams.ts +++ b/ee/packages/ddp-client/src/types/streams.ts @@ -25,6 +25,7 @@ import type { IBanner, UiKit, } from '@rocket.chat/core-typings'; +import type { LicenseLimitKind } from '@rocket.chat/license'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -69,6 +70,7 @@ export interface StreamerEvents { { key: 'public-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] }, { key: 'deleteCustomSound'; args: [{ soundData: ICustomSound }] }, { key: 'updateCustomSound'; args: [{ soundData: ICustomSound }] }, + { key: 'license'; args: [{ preventedActions: Record }] | [] }, ]; 'notify-user': [ diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 5682715d6d2b..ce949365e8a6 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -117,6 +117,70 @@ describe('Event License behaviors', () => { }); }); + /** + * This event is used to sync multiple instances of license manager + * The sync event is triggered when the license is changed, but if the validation is running due to a previous change, no sync should be triggered, avoiding multiple/loops syncs + */ + describe('sync event', () => { + it('should emit `sync` event when the license is changed', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onChange(fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 20, + behavior: 'invalidate_license', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 21); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + }); + + it('should not emit `sync` event when the license validation was triggered by a the sync method', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onChange(fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 20, + behavior: 'invalidate_license', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 21); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + fn.mockClear(); + + await expect(licenseManager.sync()).resolves.toBe(undefined); + + await expect(fn).toBeCalledTimes(0); + }); + }); + /** * this is only called when the prevent_action behavior is triggered for the first time * it will not be called again until the behavior is toggled diff --git a/ee/packages/license/src/definition/LicenseValidationOptions.ts b/ee/packages/license/src/definition/LicenseValidationOptions.ts index 6aa1e4213c62..9357b021878b 100644 --- a/ee/packages/license/src/definition/LicenseValidationOptions.ts +++ b/ee/packages/license/src/definition/LicenseValidationOptions.ts @@ -8,4 +8,5 @@ export type LicenseValidationOptions = { suppressLog?: boolean; isNewLicense?: boolean; context?: Partial<{ [K in LicenseLimitKind]: Partial> }>; + triggerSync?: boolean; }; diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts index ad1114738cce..b9d211da9b7a 100644 --- a/ee/packages/license/src/definition/events.ts +++ b/ee/packages/license/src/definition/events.ts @@ -18,4 +18,5 @@ export type LicenseEvents = ModuleValidation & validate: undefined; invalidate: undefined; module: { module: LicenseModule; valid: boolean }; + sync: undefined; }; diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index 6c80867b7ac8..f8c291edc4be 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -4,6 +4,13 @@ import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { hasModule } from '../modules'; +/** + * Invoked when the license changes some internal state. it's called to sync the license with other instances. + */ +export function onChange(this: LicenseManager, cb: () => void) { + this.on('sync', cb); +} + export function onValidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) { this.on(`valid:${feature}`, cb); diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 92b30c4af40d..e590ce7722b2 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -10,6 +10,7 @@ import { onInvalidateLicense, onLimitReached, onModule, + onChange, onToggledFeature, onValidFeature, onValidateLicense, @@ -82,6 +83,8 @@ export class LicenseImp extends LicenseManager implements License { return this.shouldPreventAction(action, 0, context); } + onChange = onChange; + onValidFeature = onValidFeature; onInvalidFeature = onInvalidFeature; diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 5987065bd697..e8be1df85eac 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -95,7 +95,27 @@ export class LicenseManager extends Emitter { } try { - await this.validateLicense({ ...options, isNewLicense: false }); + await this.validateLicense({ ...options, isNewLicense: false, triggerSync: true }); + } catch (e) { + if (e instanceof InvalidLicenseError) { + this.invalidateLicense(); + this.emit('sync'); + } + } + } + + /** + * The sync method should be called when a license from a different instance is has changed, so the local instance + * needs to be updated. This method will validate the license and update the local instance if the license is valid, but will not trigger the onSync event. + */ + + public async sync(options: Omit = {}): Promise { + if (!this.hasValidLicense()) { + return; + } + + try { + await this.validateLicense({ ...options, isNewLicense: false, triggerSync: false }); } catch (e) { if (e instanceof InvalidLicenseError) { this.invalidateLicense(); @@ -152,7 +172,11 @@ export class LicenseManager extends Emitter { return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense); } - private async validateLicense(options: LicenseValidationOptions = {}): Promise { + private async validateLicense( + options: LicenseValidationOptions = { + triggerSync: true, + }, + ): Promise { if (!this._license) { throw new InvalidLicenseError(); } @@ -195,6 +219,16 @@ export class LicenseManager extends Emitter { } licenseValidated.call(this); + + // If something changed in the license and the sync option is enabled, trigger a sync + if ( + ((!options.isNewLicense && + filterBehaviorsResult(validationResult, ['invalidate_license', 'start_fair_policy', 'prevent_installation'])) || + modulesChanged) && + options.triggerSync + ) { + this.emit('sync'); + } } public async setLicense(encryptedLicense: string, isNewLicense = true): Promise { @@ -263,6 +297,20 @@ export class LicenseManager extends Emitter { } } + public syncShouldPreventActionResults(actions: Record): void { + for (const [action, shouldPreventAction] of Object.entries(actions)) { + this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction); + } + } + + public get shouldPreventActionResultsMap(): { + [key in LicenseLimitKind]: boolean; + } { + return Object.fromEntries(this.shouldPreventActionResults.entries()) as { + [key in LicenseLimitKind]: boolean; + }; + } + public async shouldPreventAction( action: T, extraCount = 0, @@ -360,7 +408,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, - preventedActions: Object.fromEntries(this.shouldPreventActionResults.entries()) as Record, + preventedActions: this.shouldPreventActionResultsMap, limits: limits as Record, tags: license?.information.tags || [], trial: Boolean(license?.information.trial), diff --git a/packages/core-services/src/Events.ts b/packages/core-services/src/Events.ts index 2aa39588d2f0..c5f36d921655 100644 --- a/packages/core-services/src/Events.ts +++ b/packages/core-services/src/Events.ts @@ -34,6 +34,7 @@ import type { ILivechatVisitor, UiKit, } from '@rocket.chat/core-typings'; +import type { LicenseLimitKind } from '@rocket.chat/license'; import type { AutoUpdateRecord } from './types/IMeteor'; @@ -55,6 +56,9 @@ export type EventSignatures = { 'emoji.deleteCustom'(emoji: IEmoji): void; 'emoji.updateCustom'(emoji: IEmoji): void; 'license.module'(data: { module: string; valid: boolean }): void; + 'license.sync'(): void; + 'license.actions'(actions: Record, boolean>): void; + 'livechat-inquiry-queue-observer'(data: { action: string; inquiry: IInquiry }): void; 'message'(data: { action: string; message: IMessage }): void; 'meteor.clientVersionUpdated'(data: AutoUpdateRecord): void; From bba3c9da6a2a0498bdc2d9c2c182fe6e287b3e9e Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 20 Oct 2023 13:16:02 -0600 Subject: [PATCH 14/22] fix: Omnichannel webhook is not retrying requests (#30687) --- .changeset/hip-pans-argue.md | 5 +++++ .../app/livechat/server/lib/LivechatTyped.ts | 20 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 .changeset/hip-pans-argue.md diff --git a/.changeset/hip-pans-argue.md b/.changeset/hip-pans-argue.md new file mode 100644 index 000000000000..af8050383467 --- /dev/null +++ b/.changeset/hip-pans-argue.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Omnichannel webhook is not retrying requests diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 32cb5c83acd9..0d25616bda60 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -796,12 +796,15 @@ class LivechatClass { attempts = 10, ) { if (!attempts) { + Livechat.logger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' }); return; } const timeout = settings.get('Livechat_http_timeout'); const secretToken = settings.get('Livechat_secret_token'); + const webhookUrl = settings.get('Livechat_webhookUrl'); try { - const result = await fetch(settings.get('Livechat_webhookUrl'), { + Livechat.webhookLogger.debug({ msg: 'Sending webhook request', postData }); + const result = await fetch(webhookUrl, { method: 'POST', headers: { ...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }), @@ -812,17 +815,20 @@ class LivechatClass { if (result.status === 200) { metrics.totalLivechatWebhooksSuccess.inc(); - } else { - metrics.totalLivechatWebhooksFailures.inc(); + return result; } - return result; + + metrics.totalLivechatWebhooksFailures.inc(); + throw new Error(await result.text()); } catch (err) { - Livechat.webhookLogger.error({ msg: `Response error on ${11 - attempts} try ->`, err }); + const retryAfter = timeout * 4; + Livechat.webhookLogger.error({ msg: `Error response on ${11 - attempts} try ->`, err }); // try 10 times after 20 seconds each - attempts - 1 && Livechat.webhookLogger.warn(`Will try again in ${(timeout / 1000) * 4} seconds ...`); + attempts - 1 && + Livechat.webhookLogger.warn({ msg: `Webhook call failed. Retrying`, newAttemptAfterSeconds: retryAfter / 1000, webhookUrl }); setTimeout(async () => { await Livechat.sendRequest(postData, attempts - 1); - }, timeout * 4); + }, retryAfter); } } From b98b99e7a29a2673bbdd9cbeb8f448e3c053072d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 20 Oct 2023 21:04:37 -0300 Subject: [PATCH 15/22] feat: make sure every preventActions contains a value (#30708) --- ee/packages/license/src/license.ts | 44 ++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index e8be1df85eac..fb290d541cfb 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -27,6 +27,7 @@ import { isBehaviorsInResult } from './validation/isBehaviorsInResult'; import { isReadyForValidation } from './validation/isReadyForValidation'; import { runValidation } from './validation/runValidation'; import { validateFormat } from './validation/validateFormat'; +import { validateLicenseLimits } from './validation/validateLicenseLimits'; const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts']; @@ -303,12 +304,43 @@ export class LicenseManager extends Emitter { } } - public get shouldPreventActionResultsMap(): { + public async shouldPreventActionResultsMap(): Promise<{ [key in LicenseLimitKind]: boolean; - } { - return Object.fromEntries(this.shouldPreventActionResults.entries()) as { - [key in LicenseLimitKind]: boolean; - }; + }> { + const keys: LicenseLimitKind[] = [ + 'activeUsers', + 'guestUsers', + 'roomsPerGuest', + 'privateApps', + 'marketplaceApps', + 'monthlyActiveContacts', + ]; + + const items = await Promise.all( + keys.map(async (limit) => { + const cached = this.shouldPreventActionResults.get(limit as LicenseLimitKind); + + if (cached !== undefined) { + return [limit as LicenseLimitKind, cached]; + } + + const fresh = this._license + ? isBehaviorsInResult( + await validateLicenseLimits.call(this, this._license, { + behaviors: ['prevent_action'], + limits: [limit], + }), + ['prevent_action'], + ) + : false; + + this.shouldPreventActionResults.set(limit as LicenseLimitKind, fresh); + + return [limit as LicenseLimitKind, fresh]; + }), + ); + + return Object.fromEntries(items); } public async shouldPreventAction( @@ -408,7 +440,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, - preventedActions: this.shouldPreventActionResultsMap, + preventedActions: await this.shouldPreventActionResultsMap(), limits: limits as Record, tags: license?.information.tags || [], trial: Boolean(license?.information.trial), From 47303b5232393a9e8b0e5b6130f0746788955b12 Mon Sep 17 00:00:00 2001 From: Allan RIbeiro <35040806+AllanPazRibeiro@users.noreply.github.com> Date: Mon, 23 Oct 2023 09:36:14 -0300 Subject: [PATCH 16/22] chore: just some portuguese translations (#30647) --- .changeset/lazy-shoes-teach.md | 5 +++++ apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/lazy-shoes-teach.md diff --git a/.changeset/lazy-shoes-teach.md b/.changeset/lazy-shoes-teach.md new file mode 100644 index 000000000000..7737f39cd671 --- /dev/null +++ b/.changeset/lazy-shoes-teach.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +chore: adding some portugueses translations to the app details page diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 8da04e7c4e67..9812ddc46db3 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -2833,6 +2833,7 @@ "Mark_read": "Marcar como Lido", "Mark_unread": "Marcar como não lido", "Marketplace": "Marketplace", + "Marketplace_app_last_updated": "Ultima atualização {{lastUpdated}}", "Marketplace_view_marketplace": "Ver Marketplace", "Marketplace_error": "Não é possível conectar à internet, ou seu espaço de trabalho pode ser uma instalação offline.", "MAU_value": "MAU {{value}}", @@ -3522,6 +3523,7 @@ "Regular_Expressions": "Expressões regulares", "Reject_call": "Rejeitar chamada", "Release": "Versão", + "Releases": "Versões", "Religious": "Religioso", "Reload": "Recarregar", "Reload_page": "Recarregar página", @@ -3575,6 +3577,10 @@ "Request_comment_when_closing_conversation": "Solicitar comentário ao encerrar a conversa", "Request_comment_when_closing_conversation_description": "Se ativado, o agente precisará informar um comentário antes que a conversa seja encerrada.", "Request_tag_before_closing_chat": "Solicitar tag(s) antes de encerrar a conversa", + "request": "solicitar", + "requests": "solicitações", + "Requests": "Solicitações", + "Requested": "Solicitado", "Requested_At": "Solicitado em", "Requested_By": "Solicitado por", "Require": "Exigir", @@ -4972,4 +4978,4 @@ "RegisterWorkspace_Features_Omnichannel_Title": "Omnichannel", "RegisterWorkspace_Setup_Label": "E-mail da conta da nuvem", "cloud.RegisterWorkspace_Setup_Terms_Privacy": "Eu concordo com os <1>Termos e condições e a <3>Política de privacidade" -} \ No newline at end of file +} From c4325e82a734c624650fcaedbff8d02058e6da25 Mon Sep 17 00:00:00 2001 From: Marcos Spessatto Defendi Date: Mon, 23 Oct 2023 09:40:37 -0300 Subject: [PATCH 17/22] feat: add statsToken to statistics and send it to the sync process (#30181) --- .../server/functions/buildRegistrationData.ts | 2 ++ .../app/statistics/server/lib/statistics.ts | 4 +++- apps/meteor/server/cron/statistics.ts | 18 +++++++++++++----- packages/core-typings/src/IStats.ts | 1 + 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index c2bd91e82dd8..ea94db8d17a1 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -38,6 +38,7 @@ export type WorkspaceRegistrationData = { MAC: number; // activeContactsBillingMonth: number; // activeContactsYesterday: number; + statsToken?: string; }; export async function buildWorkspaceRegistrationData(contactEmail: T): Promise> { @@ -92,5 +93,6 @@ export async function buildWorkspaceRegistrationData { const rcStatistics = await statistics.get(); rcStatistics.createdAt = new Date(); - await Statistics.insertOne(rcStatistics); + const { insertedId } = await Statistics.insertOne(rcStatistics); + rcStatistics._id = insertedId; + return rcStatistics; }, }; diff --git a/apps/meteor/server/cron/statistics.ts b/apps/meteor/server/cron/statistics.ts index 7e7dea6adbc7..44dcb554824c 100644 --- a/apps/meteor/server/cron/statistics.ts +++ b/apps/meteor/server/cron/statistics.ts @@ -1,5 +1,6 @@ import { cronJobs } from '@rocket.chat/cron'; import type { Logger } from '@rocket.chat/logger'; +import { Statistics } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; @@ -8,9 +9,7 @@ import { settings } from '../../app/settings/server'; import { statistics } from '../../app/statistics/server'; async function generateStatistics(logger: Logger): Promise { - const cronStatistics: Record = await statistics.save(); - - cronStatistics.host = Meteor.absoluteUrl(); + const cronStatistics = await statistics.save(); if (!settings.get('Statistics_reporting')) { return; @@ -20,11 +19,20 @@ async function generateStatistics(logger: Logger): Promise { const token = await getWorkspaceAccessToken(); const headers = { ...(token && { Authorization: `Bearer ${token}` }) }; - await fetch('https://collector.rocket.chat/', { + const response = await fetch('https://collector.rocket.chat/', { method: 'POST', - body: cronStatistics, + body: { + ...cronStatistics, + host: Meteor.absoluteUrl(), + }, headers, }); + + const { statsToken } = await response.json(); + + if (statsToken != null) { + await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } }); + } } catch (error) { /* error*/ logger.warn('Failed to send usage report'); diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 7fd5cd8218bc..0df389f2dd86 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -227,4 +227,5 @@ export interface IStats { webRTCEnabled: boolean; webRTCEnabledForOmnichannel: boolean; omnichannelWebRTCCalls: number; + statsToken?: string; } From a82d8c2bb0a2b5f19f69e31af593fdb02df4ca77 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Oct 2023 07:19:10 -0600 Subject: [PATCH 18/22] feat: Add pagination & tooltips to agent's dropdown on forwarding modal (#30629) --- .changeset/rotten-dryers-allow.md | 5 +++ .../app/livechat/imports/server/rest/users.ts | 13 ++++--- .../app/livechat/server/api/lib/users.ts | 39 +++++++++++++++++-- .../client/components/AutoCompleteAgent.tsx | 30 +++++++------- .../Omnichannel/hooks/useAgentsList.ts | 17 +++++--- .../Omnichannel/modals/ForwardChatModal.tsx | 35 ++++------------- .../rocketchat-i18n/i18n/en.i18n.json | 1 + .../rocketchat-i18n/i18n/pt-BR.i18n.json | 1 + ...channel-transfer-to-another-agents.spec.ts | 4 +- .../page-objects/fragments/home-content.ts | 14 ++----- .../end-to-end/api/livechat/01-agents.ts | 20 ++++++++++ packages/rest-typings/src/v1/omnichannel.ts | 24 +++++++++++- 12 files changed, 132 insertions(+), 71 deletions(-) create mode 100644 .changeset/rotten-dryers-allow.md diff --git a/.changeset/rotten-dryers-allow.md b/.changeset/rotten-dryers-allow.md new file mode 100644 index 000000000000..154dea572780 --- /dev/null +++ b/.changeset/rotten-dryers-allow.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Add pagination & tooltips to agent's dropdown on forwarding modal diff --git a/apps/meteor/app/livechat/imports/server/rest/users.ts b/apps/meteor/app/livechat/imports/server/rest/users.ts index 680bdc7e80d2..196970583249 100644 --- a/apps/meteor/app/livechat/imports/server/rest/users.ts +++ b/apps/meteor/app/livechat/imports/server/rest/users.ts @@ -9,16 +9,15 @@ import { hasAtLeastOnePermissionAsync } from '../../../../authorization/server/f import { findAgents, findManagers } from '../../../server/api/lib/users'; import { Livechat } from '../../../server/lib/Livechat'; +const emptyStringArray: string[] = []; + API.v1.addRoute( 'livechat/users/:type', { authRequired: true, permissionsRequired: { - GET: { - permissions: ['manage-livechat-agents'], - operation: 'hasAll', - }, - POST: { permissions: ['view-livechat-manager'], operation: 'hasAll' }, + 'POST': ['view-livechat-manager'], + '*': emptyStringArray, }, validateParams: { GET: isLivechatUsersManagerGETProps, @@ -39,9 +38,13 @@ API.v1.addRoute( return API.v1.unauthorized(); } + const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams; return API.v1.success( await findAgents({ text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/app/livechat/server/api/lib/users.ts b/apps/meteor/app/livechat/server/api/lib/users.ts index 948cde0f8bfd..49ac5682a6c0 100644 --- a/apps/meteor/app/livechat/server/api/lib/users.ts +++ b/apps/meteor/app/livechat/server/api/lib/users.ts @@ -1,6 +1,7 @@ import type { ILivechatAgent, IRole } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { FilterOperators } from 'mongodb'; /** * @param {IRole['_id']} role the role id @@ -10,18 +11,39 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; async function findUsers({ role, text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { role: IRole['_id']; text?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise<{ users: ILivechatAgent[]; count: number; offset: number; total: number }> { - const query = {}; + const query: FilterOperators = {}; + const orConditions: FilterOperators['$or'] = []; if (text) { const filterReg = new RegExp(escapeRegExp(text), 'i'); - Object.assign(query, { - $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }], - }); + orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] }); + } + + if (onlyAvailable) { + query.statusLivechat = 'available'; + } + + if (excludeId) { + query._id = { $ne: excludeId }; + } + + if (!showIdleAgents) { + orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] }); + } + + if (orConditions.length) { + query.$and = orConditions; } const [ @@ -52,14 +74,23 @@ async function findUsers({ } export async function findAgents({ text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { text?: string; + onlyAvailable: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise> { return findUsers({ role: 'livechat-agent', text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/client/components/AutoCompleteAgent.tsx b/apps/meteor/client/components/AutoCompleteAgent.tsx index b4e287bcc4ae..f2cbebe46920 100644 --- a/apps/meteor/client/components/AutoCompleteAgent.tsx +++ b/apps/meteor/client/components/AutoCompleteAgent.tsx @@ -11,18 +11,26 @@ type AutoCompleteAgentProps = { value: string; error?: string; placeholder?: string; - onChange: (value: string) => void; haveAll?: boolean; haveNoAgentsSelectedOption?: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; + withTitle?: boolean; + onChange: (value: string) => void; }; const AutoCompleteAgent = ({ value, error, placeholder, - onChange, haveAll = false, haveNoAgentsSelectedOption = false, + excludeId, + showIdleAgents = false, + onlyAvailable = false, + withTitle = false, + onChange, }: AutoCompleteAgentProps): ReactElement => { const [agentsFilter, setAgentsFilter] = useState(''); @@ -30,26 +38,16 @@ const AutoCompleteAgent = ({ const { itemsList: AgentsList, loadMoreItems: loadMoreAgents } = useAgentsList( useMemo( - () => ({ text: debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption }), - [debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption], + () => ({ text: debouncedAgentsFilter, onlyAvailable, haveAll, haveNoAgentsSelectedOption, excludeId, showIdleAgents }), + [debouncedAgentsFilter, excludeId, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents], ), ); const { phase: agentsPhase, itemCount: agentsTotal, items: agentsItems } = useRecordList(AgentsList); - const sortedByName = agentsItems.sort((a, b) => { - if (a.label > b.label) { - return 1; - } - if (a.label < b.label) { - return -1; - } - - return 0; - }); - return ( void} - options={sortedByName} + options={agentsItems} data-qa='autocomplete-agent' endReached={ agentsPhase === AsyncStatePhase.LOADING ? (): void => undefined : (start): void => loadMoreAgents(start, Math.min(50, agentsTotal)) diff --git a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts index b854866184f7..e2f6f80f2355 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts @@ -9,6 +9,9 @@ type AgentsListOptions = { text: string; haveAll: boolean; haveNoAgentsSelectedOption: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; }; type AgentOption = { value: string; label: string; _updatedAt: Date; _id: string }; @@ -26,6 +29,7 @@ export const useAgentsList = ( const reload = useCallback(() => setItemsList(new RecordList()), []); const getAgents = useEndpoint('GET', '/v1/livechat/users/agent'); + const { text, onlyAvailable = false, showIdleAgents = false, excludeId, haveAll, haveNoAgentsSelectedOption } = options; useComponentDidUpdate(() => { options && reload(); @@ -34,7 +38,10 @@ export const useAgentsList = ( const fetchData = useCallback( async (start, end) => { const { users: agents, total } = await getAgents({ - ...(options.text && { text: options.text }), + ...(text && { text }), + ...(excludeId && { excludeId }), + showIdleAgents, + onlyAvailable, offset: start, count: end + start, sort: `{ "name": 1 }`, @@ -43,14 +50,14 @@ export const useAgentsList = ( const items = agents.map((agent) => { const agentOption = { _updatedAt: new Date(agent._updatedAt), - label: agent.username || agent._id, + label: `${agent.name || agent._id} (@${agent.username})`, value: agent._id, _id: agent._id, }; return agentOption; }); - options.haveAll && + haveAll && items.unshift({ label: t('All'), value: 'all', @@ -58,7 +65,7 @@ export const useAgentsList = ( _id: 'all', }); - options.haveNoAgentsSelectedOption && + haveNoAgentsSelectedOption && items.unshift({ label: t('Empty_no_agent_selected'), value: 'no-agent-selected', @@ -71,7 +78,7 @@ export const useAgentsList = ( itemCount: total + 1, }; }, - [getAgents, options.haveAll, options.haveNoAgentsSelectedOption, options.text, t], + [excludeId, getAgents, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents, t, text], ); const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25); diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index bdbde6b05acd..a4d095fdb90d 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -19,7 +19,7 @@ import { useForm } from 'react-hook-form'; import { useRecordList } from '../../../hooks/lists/useRecordList'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -import UserAutoComplete from '../../UserAutoComplete'; +import AutoCompleteAgent from '../../AutoCompleteAgent'; import { useDepartmentsList } from '../hooks/useDepartmentsList'; const ForwardChatModal = ({ @@ -53,28 +53,6 @@ const ForwardChatModal = ({ ); const { phase: departmentsPhase, items: departments, itemCount: departmentsTotal } = useRecordList(departmentsList); - const _id = { $ne: room.servedBy?._id }; - const conditions = { - _id, - ...(!idleAgentsAllowedForForwarding && { - $or: [ - { - status: { - $exists: true, - $ne: 'offline', - }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', - }, - ], - }), - statusLivechat: 'available', - }; - const endReached = useCallback( (start) => { if (departmentsPhase !== AsyncStatePhase.LOADING) { @@ -134,13 +112,16 @@ const ForwardChatModal = ({ {t('Forward_to_user')} - { setValue('username', value); }} - value={getValues().username} /> diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 46805a1f0e3e..d260037d2aab 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5453,6 +5453,7 @@ "Username_title": "Register username", "Username_has_been_updated": "Username has been updated", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} wants to start OTR. Do you want to accept?", + "Username_name_email": "Username, name or e-mail", "Users": "Users", "Users must use Two Factor Authentication": "Users must use Two Factor Authentication", "Users_added": "The users have been added", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 9812ddc46db3..b31c69d477e0 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -4581,6 +4581,7 @@ "Username_Placeholder": "Digite os nomes de usuário...", "Username_title": "Cadastre um nome de usuário", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} quer começar OTR. Você aceita?", + "Username_name_email": "Nome de usuário, nome ou e-mail", "Users": "Usuários", "Users must use Two Factor Authentication": "Os usuários devem usar autenticação de dois fatores", "Users_added": "Os usuários foram adicionados", diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts index 3c74065a9a84..dc31f54be934 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts @@ -65,6 +65,7 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent2.poHomeOmnichannel.sidenav.switchStatus('offline'); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); await expect(agent1.page.locator('text=Empty')).toBeVisible(); @@ -76,8 +77,9 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent1.poHomeOmnichannel.sidenav.getSidebarItemByName(newVisitor.name).click(); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); - await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2"').click(); + await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2 (@user2)"').click(); await agent1.poHomeOmnichannel.content.inputModalAgentForwardComment.type('any_comment'); await agent1.poHomeOmnichannel.content.btnModalConfirm.click(); await expect(agent1.poHomeOmnichannel.toastSuccess).toBeVisible(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index cb8c8b089095..2ba8cd6428d9 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -197,7 +197,7 @@ export class HomeContent { } get inputModalAgentUserName(): Locator { - return this.page.locator('#modal-root input:nth-child(1)'); + return this.page.locator('#modal-root input[placeholder="Username, name or e-mail"]'); } get inputModalAgentForwardComment(): Locator { @@ -237,16 +237,8 @@ export class HomeContent { async openLastThreadMessageMenu(): Promise { await this.page.locator('//main//aside >> [data-qa-type="message"]').last().hover(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .waitFor(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .click(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').waitFor(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').click(); } async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise { diff --git a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts index 83efe2c96aa2..6f9120ea9094 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts @@ -94,6 +94,26 @@ describe('LIVECHAT - Agents', function () { expect(agentRecentlyCreated?._id).to.be.equal(agent._id); }); }); + it('should return an array of available agents', async () => { + await updatePermission('edit-omnichannel-contact', ['admin']); + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('manage-livechat-agents', ['admin']); + + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { statusLivechat: string }) => u.statusLivechat === 'available')).to.be.true; + }); + }); it('should return an array of managers', async () => { await updatePermission('view-livechat-manager', ['admin']); await updatePermission('manage-livechat-agents', ['admin']); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index bebea2856861..3baeae111202 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -749,7 +749,13 @@ const LivechatDepartmentsByUnitIdSchema = { export const isLivechatDepartmentsByUnitIdProps = ajv.compile(LivechatDepartmentsByUnitIdSchema); -type LivechatUsersManagerGETProps = PaginatedRequest<{ text?: string; fields?: string }>; +type LivechatUsersManagerGETProps = PaginatedRequest<{ + text?: string; + fields?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; +}>; const LivechatUsersManagerGETSchema = { type: 'object', @@ -758,6 +764,18 @@ const LivechatUsersManagerGETSchema = { type: 'string', nullable: true, }, + onlyAvailable: { + type: 'string', + nullable: true, + }, + excludeId: { + type: 'string', + nullable: true, + }, + showIdleAgents: { + type: 'boolean', + nullable: true, + }, count: { type: 'number', nullable: true, @@ -3386,7 +3404,9 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/users/agent': { - GET: (params: PaginatedRequest<{ text?: string }>) => PaginatedResult<{ + GET: ( + params: PaginatedRequest<{ text?: string; onlyAvailable?: boolean; excludeId?: string; showIdleAgents?: boolean }>, + ) => PaginatedResult<{ users: (ILivechatAgent & { departments: string[] })[]; }>; POST: (params: LivechatUsersManagerPOSTProps) => { success: boolean }; From d26a61c388dcc8dcaa717f2b2a92ad89bfcf4720 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Oct 2023 09:26:04 -0600 Subject: [PATCH 19/22] fix: `nextAgent` widget call sending an array of chars instead of `department` query (#30689) --- packages/livechat/src/lib/triggers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/livechat/src/lib/triggers.js b/packages/livechat/src/lib/triggers.js index 4cf01090587c..efa9e9bdc65d 100644 --- a/packages/livechat/src/lib/triggers.js +++ b/packages/livechat/src/lib/triggers.js @@ -34,7 +34,7 @@ const getAgent = (triggerAction) => { let agent; try { - agent = await Livechat.nextAgent(department); + agent = await Livechat.nextAgent({ department }); } catch (error) { return reject(error); } From 18ed36cdd1ae417ffe8549b969e51e836ab8941c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Mon, 23 Oct 2023 13:53:42 -0300 Subject: [PATCH 20/22] fix: custom-css injection (#30716) --- .changeset/cuddly-ties-run.md | 5 +++++ apps/meteor/app/ui-master/server/index.js | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .changeset/cuddly-ties-run.md diff --git a/.changeset/cuddly-ties-run.md b/.changeset/cuddly-ties-run.md new file mode 100644 index 000000000000..cb3873899841 --- /dev/null +++ b/.changeset/cuddly-ties-run.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: custom-css injection diff --git a/apps/meteor/app/ui-master/server/index.js b/apps/meteor/app/ui-master/server/index.js index f9e335451d74..34a9618d2db6 100644 --- a/apps/meteor/app/ui-master/server/index.js +++ b/apps/meteor/app/ui-master/server/index.js @@ -120,8 +120,6 @@ Meteor.startup(() => { })(__meteor_runtime_config__.ROOT_URL_PATH_PREFIX); injectIntoHead('base', ``); - - injectIntoHead('css-theme', ''); }); const renderDynamicCssList = withDebouncing({ wait: 500 })(async () => { From dfe4b39b89ff97ff1cf557dc0f8e821d610ac9c7 Mon Sep 17 00:00:00 2001 From: Guilherme Jun Grillo <48109548+guijun13@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:40:54 -0300 Subject: [PATCH 21/22] i18n: Add accessibility and appearance finnish translation (#30718) --- apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json index 79a27d83cbe6..f482e42419a4 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json @@ -30,6 +30,7 @@ "A_secure_and_highly_private_self-managed_solution_for_conference_calls": "Suojattu ja vahvasti yksityinen itsepalveluratkaisu neuvottelupuheluille.", "A_workspace_admin_needs_to_install_and_configure_a_conference_call_app": "Työtilan järjestelmänvalvojan on asennettava ja määritettävä neuvottelupuhelusovellus.", "An_app_needs_to_be_installed_and_configured": "Sovellus on asennettavaa ja määritettävä.", + "Accessibility_and_Appearance": "Helppokäyttöisyys ja ulkoasu", "Accept_Call": "Hyväksy puhelu", "Accept": "Hyväksy", "Accept_incoming_livechat_requests_even_if_there_are_no_online_agents": "Hyväksy saapuvat monikanavapyynnöt, vaikka agentteja ei ole paikalla", @@ -5749,4 +5750,4 @@ "Uninstall_grandfathered_app": "Poistetaanko {{appName}}?", "App_will_lose_grandfathered_status": "**Tämä {{context}}sovellus menettää aikaisemmin käytetössä olleen sovelluksen tilansa.** \n \nYhteisöversion työtiloissa voi olla käytössä enintään {{limit}} {{context}} sovellusta. aikaisemmin Aikaisemmin käytössä olleet sovellukset lasketaan mukaan rajoitukseen, mutta rajoitusta ei sovelleta niihin.", "Theme_Appearence": "Teeman ulkoasu" -} \ No newline at end of file +} From 72653e678047edc45ba46f01eb228676dc281a28 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Mon, 23 Oct 2023 15:55:29 -0300 Subject: [PATCH 22/22] chore: simplified useLicense return value (#30719) --- apps/meteor/client/hooks/useLicense.ts | 26 +++++++------------ .../client/views/admin/info/LicenseCard.tsx | 2 +- .../client/views/hooks/useUpgradeTabParams.ts | 6 ++--- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 8ba594c5b5d3..ec07e1702bed 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -1,13 +1,15 @@ +import type { Serialized } from '@rocket.chat/core-typings'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint, usePermission, useSingleStream } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSingleStream } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -export const useLicense = (): UseQueryResult> => { +type LicenseDataType = Awaited>['license']; + +export const useLicense = (): UseQueryResult> => { const getLicenses = useEndpoint('GET', '/v1/licenses.info'); - const canViewLicense = usePermission('view-privileged-setting'); const queryClient = useQueryClient(); @@ -23,17 +25,9 @@ export const useLicense = (): UseQueryResult notify('license', () => invalidate()), [notify, invalidate]); - return useQuery( - ['licenses', 'getLicenses'], - () => { - if (!canViewLicense) { - throw new Error('unauthorized api call'); - } - return getLicenses({}); - }, - { - staleTime: Infinity, - keepPreviousData: true, - }, - ); + return useQuery(['licenses', 'getLicenses'], () => getLicenses({}), { + staleTime: Infinity, + keepPreviousData: true, + select: (data) => data.license, + }); }; diff --git a/apps/meteor/client/views/admin/info/LicenseCard.tsx b/apps/meteor/client/views/admin/info/LicenseCard.tsx index 8aab636f4720..195121932e80 100644 --- a/apps/meteor/client/views/admin/info/LicenseCard.tsx +++ b/apps/meteor/client/views/admin/info/LicenseCard.tsx @@ -56,7 +56,7 @@ const LicenseCard = (): ReactElement => { ); } - const { activeModules } = request.data.license; + const { activeModules } = request.data; const hasEngagement = activeModules.includes('engagement-dashboard'); const hasOmnichannel = activeModules.includes('livechat-enterprise'); diff --git a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts index 1d152b08d5b9..abfa9da251dc 100644 --- a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts +++ b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts @@ -13,11 +13,11 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const { data: registrationStatusData, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); const registered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; - const hasValidLicense = Boolean(licensesData?.license?.license ?? false); + const hasValidLicense = Boolean(licensesData?.license ?? false); const hadExpiredTrials = cloudWorkspaceHadTrial ?? false; - const isTrial = Boolean(licensesData?.license?.trial); - const trialEndDateStr = licensesData?.license?.license?.information?.visualExpiration; + const isTrial = Boolean(licensesData?.trial); + const trialEndDateStr = licensesData?.license?.information?.visualExpiration; const trialEndDate = trialEndDateStr ? format(new Date(trialEndDateStr), 'yyyy-MM-dd') : undefined; const upgradeTabType = getUpgradeTabType({