users.js 99 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630
  1. import { ReactiveCache, ReactiveMiniMongoIndex } from '/imports/reactiveCache';
  2. import { Random } from 'meteor/random';
  3. import { SyncedCron } from 'meteor/percolate:synced-cron';
  4. import { TAPi18n } from '/imports/i18n';
  5. import ImpersonatedUsers from './impersonatedUsers';
  6. // import { Index, MongoDBEngine } from 'meteor/easy:search'; // Temporarily disabled due to compatibility issues
  7. // Sandstorm context is detected using the METEOR_SETTINGS environment variable
  8. // in the package definition.
  9. const isSandstorm =
  10. Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm;
  11. Users = Meteor.users;
  12. // Public-board collapse persistence helpers (cookie-based for non-logged-in users)
  13. if (Meteor.isClient) {
  14. const readCookieMap = name => {
  15. try {
  16. const stored = typeof document !== 'undefined' ? document.cookie : '';
  17. const cookies = stored.split(';').map(c => c.trim());
  18. let json = '{}';
  19. for (const c of cookies) {
  20. if (c.startsWith(name + '=')) {
  21. json = decodeURIComponent(c.substring(name.length + 1));
  22. break;
  23. }
  24. }
  25. return JSON.parse(json || '{}');
  26. } catch (e) {
  27. console.warn('Error parsing collapse cookie', name, e);
  28. return {};
  29. }
  30. };
  31. const writeCookieMap = (name, data) => {
  32. try {
  33. const serialized = encodeURIComponent(JSON.stringify(data || {}));
  34. const maxAge = 60 * 60 * 24 * 365; // 1 year
  35. document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`;
  36. } catch (e) {
  37. console.warn('Error writing collapse cookie', name, e);
  38. }
  39. };
  40. Users.getPublicCollapsedList = (boardId, listId) => {
  41. if (!boardId || !listId) return null;
  42. const data = readCookieMap('wekan-collapsed-lists');
  43. if (data[boardId] && typeof data[boardId][listId] === 'boolean') {
  44. return data[boardId][listId];
  45. }
  46. return null;
  47. };
  48. Users.setPublicCollapsedList = (boardId, listId, collapsed) => {
  49. if (!boardId || !listId) return false;
  50. const data = readCookieMap('wekan-collapsed-lists');
  51. if (!data[boardId]) data[boardId] = {};
  52. data[boardId][listId] = !!collapsed;
  53. writeCookieMap('wekan-collapsed-lists', data);
  54. return true;
  55. };
  56. Users.getPublicCollapsedSwimlane = (boardId, swimlaneId) => {
  57. if (!boardId || !swimlaneId) return null;
  58. const data = readCookieMap('wekan-collapsed-swimlanes');
  59. if (data[boardId] && typeof data[boardId][swimlaneId] === 'boolean') {
  60. return data[boardId][swimlaneId];
  61. }
  62. return null;
  63. };
  64. Users.setPublicCollapsedSwimlane = (boardId, swimlaneId, collapsed) => {
  65. if (!boardId || !swimlaneId) return false;
  66. const data = readCookieMap('wekan-collapsed-swimlanes');
  67. if (!data[boardId]) data[boardId] = {};
  68. data[boardId][swimlaneId] = !!collapsed;
  69. writeCookieMap('wekan-collapsed-swimlanes', data);
  70. return true;
  71. };
  72. Users.getPublicCardCollapsed = () => {
  73. const data = readCookieMap('wekan-card-collapsed');
  74. return typeof data.state === 'boolean' ? data.state : null;
  75. };
  76. Users.setPublicCardCollapsed = collapsed => {
  77. writeCookieMap('wekan-card-collapsed', { state: !!collapsed });
  78. return true;
  79. };
  80. }
  81. const allowedSortValues = [
  82. '-modifiedAt',
  83. 'modifiedAt',
  84. '-title',
  85. 'title',
  86. '-sort',
  87. 'sort',
  88. ];
  89. const defaultSortBy = allowedSortValues[0];
  90. /**
  91. * A User in wekan
  92. */
  93. Users.attachSchema(
  94. new SimpleSchema({
  95. username: {
  96. /**
  97. * the username of the user
  98. */
  99. type: String,
  100. optional: true,
  101. // eslint-disable-next-line consistent-return
  102. autoValue() {
  103. if (this.isInsert && !this.isSet) {
  104. const name = this.field('profile.fullname');
  105. if (name.isSet) {
  106. return name.value.toLowerCase().replace(/\s/g, '');
  107. }
  108. }
  109. },
  110. },
  111. orgs: {
  112. /**
  113. * the list of organizations that a user belongs to
  114. */
  115. type: [Object],
  116. optional: true,
  117. },
  118. 'orgs.$.orgId': {
  119. /**
  120. * The uniq ID of the organization
  121. */
  122. type: String,
  123. },
  124. 'orgs.$.orgDisplayName': {
  125. /**
  126. * The display name of the organization
  127. */
  128. type: String,
  129. },
  130. teams: {
  131. /**
  132. * the list of teams that a user belongs to
  133. */
  134. type: [Object],
  135. optional: true,
  136. },
  137. 'teams.$.teamId': {
  138. /**
  139. * The uniq ID of the team
  140. */
  141. type: String,
  142. },
  143. 'teams.$.teamDisplayName': {
  144. /**
  145. * The display name of the team
  146. */
  147. type: String,
  148. },
  149. emails: {
  150. /**
  151. * the list of emails attached to a user
  152. */
  153. type: [Object],
  154. optional: true,
  155. },
  156. 'emails.$.address': {
  157. /**
  158. * The email address
  159. */
  160. type: String,
  161. regEx: SimpleSchema.RegEx.Email,
  162. },
  163. 'emails.$.verified': {
  164. /**
  165. * Has the email been verified
  166. */
  167. type: Boolean,
  168. },
  169. createdAt: {
  170. /**
  171. * creation date of the user
  172. */
  173. type: Date,
  174. // eslint-disable-next-line consistent-return
  175. autoValue() {
  176. if (this.isInsert) {
  177. return new Date();
  178. } else if (this.isUpsert) {
  179. return {
  180. $setOnInsert: new Date(),
  181. };
  182. } else {
  183. this.unset();
  184. }
  185. },
  186. },
  187. modifiedAt: {
  188. type: Date,
  189. denyUpdate: false,
  190. // eslint-disable-next-line consistent-return
  191. autoValue() {
  192. if (this.isInsert || this.isUpsert || this.isUpdate) {
  193. return new Date();
  194. } else {
  195. this.unset();
  196. }
  197. },
  198. },
  199. profile: {
  200. /**
  201. * profile settings
  202. */
  203. type: Object,
  204. optional: true,
  205. // eslint-disable-next-line consistent-return
  206. autoValue() {
  207. if (this.isInsert && !this.isSet) {
  208. return {
  209. boardView: 'board-view-swimlanes',
  210. };
  211. }
  212. },
  213. },
  214. 'profile.avatarUrl': {
  215. /**
  216. * URL of the avatar of the user
  217. */
  218. type: String,
  219. optional: true,
  220. },
  221. 'profile.emailBuffer': {
  222. /**
  223. * list of email buffers of the user
  224. */
  225. type: [String],
  226. optional: true,
  227. },
  228. 'profile.fullname': {
  229. /**
  230. * full name of the user
  231. */
  232. type: String,
  233. optional: true,
  234. },
  235. 'profile.showDesktopDragHandles': {
  236. /**
  237. * does the user want to show desktop drag handles?
  238. */
  239. type: Boolean,
  240. optional: true,
  241. },
  242. 'profile.GreyIcons': {
  243. /**
  244. * per-user preference to render unicode icons in grey
  245. */
  246. type: Boolean,
  247. optional: true,
  248. },
  249. 'profile.cardMaximized': {
  250. /**
  251. * has user clicked maximize card?
  252. */
  253. type: Boolean,
  254. optional: true,
  255. },
  256. 'profile.cardCollapsed': {
  257. /**
  258. * has user collapsed the card details?
  259. */
  260. type: Boolean,
  261. optional: true,
  262. },
  263. 'profile.customFieldsGrid': {
  264. /**
  265. * has user at card Custom Fields have Grid (false) or one per row (true) layout?
  266. */
  267. type: Boolean,
  268. optional: true,
  269. },
  270. 'profile.hiddenMinicardLabelText': {
  271. /**
  272. * does the user want to hide minicard label texts?
  273. */
  274. type: Boolean,
  275. optional: true,
  276. },
  277. 'profile.initials': {
  278. /**
  279. * initials of the user
  280. */
  281. type: String,
  282. optional: true,
  283. },
  284. 'profile.boardWorkspacesTree': {
  285. /**
  286. * Per-user spaces tree for All Boards page
  287. */
  288. type: Array,
  289. optional: true,
  290. },
  291. 'profile.boardWorkspacesTree.$': {
  292. /**
  293. * Space node: { id: String, name: String, children: Array<node> }
  294. */
  295. type: Object,
  296. blackbox: true,
  297. optional: true,
  298. },
  299. 'profile.boardWorkspaceAssignments': {
  300. /**
  301. * Per-user map of boardId -> spaceId
  302. */
  303. type: Object,
  304. optional: true,
  305. blackbox: true,
  306. },
  307. 'profile.invitedBoards': {
  308. /**
  309. * board IDs the user has been invited to
  310. */
  311. type: [String],
  312. optional: true,
  313. },
  314. 'profile.language': {
  315. /**
  316. * language of the user
  317. */
  318. type: String,
  319. optional: true,
  320. },
  321. 'profile.moveAndCopyDialog': {
  322. /**
  323. * move and copy card dialog
  324. */
  325. type: Object,
  326. optional: true,
  327. blackbox: true,
  328. },
  329. 'profile.moveAndCopyDialog.$.boardId': {
  330. /**
  331. * last selected board id
  332. */
  333. type: String,
  334. },
  335. 'profile.moveAndCopyDialog.$.swimlaneId': {
  336. /**
  337. * last selected swimlane id
  338. */
  339. type: String,
  340. },
  341. 'profile.moveAndCopyDialog.$.listId': {
  342. /**
  343. * last selected list id
  344. */
  345. type: String,
  346. },
  347. 'profile.moveChecklistDialog': {
  348. /**
  349. * move checklist dialog
  350. */
  351. type: Object,
  352. optional: true,
  353. blackbox: true,
  354. },
  355. 'profile.moveChecklistDialog.$.boardId': {
  356. /**
  357. * last selected board id
  358. */
  359. type: String,
  360. },
  361. 'profile.moveChecklistDialog.$.swimlaneId': {
  362. /**
  363. * last selected swimlane id
  364. */
  365. type: String,
  366. },
  367. 'profile.moveChecklistDialog.$.listId': {
  368. /**
  369. * last selected list id
  370. */
  371. type: String,
  372. },
  373. 'profile.moveChecklistDialog.$.cardId': {
  374. /**
  375. * last selected card id
  376. */
  377. type: String,
  378. },
  379. 'profile.copyChecklistDialog': {
  380. /**
  381. * copy checklist dialog
  382. */
  383. type: Object,
  384. optional: true,
  385. blackbox: true,
  386. },
  387. 'profile.copyChecklistDialog.$.boardId': {
  388. /**
  389. * last selected board id
  390. */
  391. type: String,
  392. },
  393. 'profile.copyChecklistDialog.$.swimlaneId': {
  394. /**
  395. * last selected swimlane id
  396. */
  397. type: String,
  398. },
  399. 'profile.copyChecklistDialog.$.listId': {
  400. /**
  401. * last selected list id
  402. */
  403. type: String,
  404. },
  405. 'profile.copyChecklistDialog.$.cardId': {
  406. /**
  407. * last selected card id
  408. */
  409. type: String,
  410. },
  411. 'profile.notifications': {
  412. /**
  413. * enabled notifications for the user
  414. */
  415. type: [Object],
  416. optional: true,
  417. },
  418. 'profile.notifications.$.activity': {
  419. /**
  420. * The id of the activity this notification references
  421. */
  422. type: String,
  423. },
  424. 'profile.notifications.$.read': {
  425. /**
  426. * the date on which this notification was read
  427. */
  428. type: Date,
  429. optional: true,
  430. },
  431. 'profile.rescueCardDescription': {
  432. /**
  433. * show dialog for saving card description on unintentional card closing
  434. */
  435. type: Boolean,
  436. optional: true,
  437. },
  438. 'profile.showCardsCountAt': {
  439. /**
  440. * showCardCountAt field of the user
  441. */
  442. type: Number,
  443. optional: true,
  444. },
  445. 'profile.startDayOfWeek': {
  446. /**
  447. * startDayOfWeek field of the user
  448. */
  449. type: Number,
  450. optional: true,
  451. },
  452. 'profile.starredBoards': {
  453. /**
  454. * list of starred board IDs
  455. */
  456. type: [String],
  457. optional: true,
  458. },
  459. 'profile.icode': {
  460. /**
  461. * icode
  462. */
  463. type: String,
  464. optional: true,
  465. },
  466. 'profile.boardView': {
  467. /**
  468. * boardView field of the user
  469. */
  470. type: String,
  471. optional: true,
  472. allowedValues: [
  473. 'board-view-swimlanes',
  474. 'board-view-lists',
  475. 'board-view-cal',
  476. 'board-view-gantt',
  477. ],
  478. },
  479. 'profile.listSortBy': {
  480. /**
  481. * default sort list for user
  482. */
  483. type: String,
  484. optional: true,
  485. defaultValue: defaultSortBy,
  486. allowedValues: allowedSortValues,
  487. },
  488. 'profile.templatesBoardId': {
  489. /**
  490. * Reference to the templates board
  491. */
  492. type: String,
  493. defaultValue: '',
  494. },
  495. 'profile.cardTemplatesSwimlaneId': {
  496. /**
  497. * Reference to the card templates swimlane Id
  498. */
  499. type: String,
  500. defaultValue: '',
  501. },
  502. 'profile.listTemplatesSwimlaneId': {
  503. /**
  504. * Reference to the list templates swimlane Id
  505. */
  506. type: String,
  507. defaultValue: '',
  508. },
  509. 'profile.boardTemplatesSwimlaneId': {
  510. /**
  511. * Reference to the board templates swimlane Id
  512. */
  513. type: String,
  514. defaultValue: '',
  515. },
  516. 'profile.listWidths': {
  517. /**
  518. * User-specified width of each list (or nothing if default).
  519. * profile[boardId][listId] = width;
  520. */
  521. type: Object,
  522. defaultValue: {},
  523. blackbox: true,
  524. },
  525. 'profile.listConstraints': {
  526. /**
  527. * User-specified constraint of each list (or nothing if default).
  528. * profile[boardId][listId] = constraint;
  529. */
  530. type: Object,
  531. defaultValue: {},
  532. blackbox: true,
  533. },
  534. 'profile.autoWidthBoards': {
  535. /**
  536. * User-specified flag for enabling auto-width for boards (false is the default).
  537. * profile[boardId][listId] = constraint;
  538. */
  539. type: Object,
  540. defaultValue: {},
  541. blackbox: true,
  542. },
  543. 'profile.swimlaneHeights': {
  544. /**
  545. * User-specified heights of each swimlane (or nothing if default).
  546. * profile[boardId][swimlaneId] = height;
  547. */
  548. type: Object,
  549. defaultValue: {},
  550. blackbox: true,
  551. },
  552. 'profile.collapsedLists': {
  553. /**
  554. * Per-user collapsed state for lists.
  555. * profile[boardId][listId] = true|false
  556. */
  557. type: Object,
  558. defaultValue: {},
  559. blackbox: true,
  560. },
  561. 'profile.collapsedSwimlanes': {
  562. /**
  563. * Per-user collapsed state for swimlanes.
  564. * profile[boardId][swimlaneId] = true|false
  565. */
  566. type: Object,
  567. defaultValue: {},
  568. blackbox: true,
  569. },
  570. 'profile.keyboardShortcuts': {
  571. /**
  572. * User-specified state of keyboard shortcut activation.
  573. */
  574. type: Boolean,
  575. defaultValue: false,
  576. },
  577. 'profile.verticalScrollbars': {
  578. /**
  579. * User-specified state of vertical scrollbars visibility.
  580. */
  581. type: Boolean,
  582. defaultValue: true,
  583. },
  584. 'profile.showWeekOfYear': {
  585. /**
  586. * User-specified state of week-of-year in date displays.
  587. */
  588. type: Boolean,
  589. defaultValue: true,
  590. },
  591. 'profile.dateFormat': {
  592. /**
  593. * User-specified date format for displaying dates (includes time HH:MM).
  594. */
  595. type: String,
  596. optional: true,
  597. allowedValues: ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM-DD-YYYY'],
  598. defaultValue: 'YYYY-MM-DD',
  599. },
  600. 'profile.zoomLevel': {
  601. /**
  602. * User-specified zoom level for board view (1.0 = 100%, 1.5 = 150%, etc.)
  603. */
  604. type: Number,
  605. defaultValue: 1.0,
  606. min: 0.5,
  607. max: 3.0,
  608. },
  609. 'profile.mobileMode': {
  610. /**
  611. * User-specified mobile/desktop mode toggle
  612. */
  613. type: Boolean,
  614. defaultValue: false,
  615. },
  616. 'profile.cardZoom': {
  617. /**
  618. * User-specified zoom level for card details (1.0 = 100%, 1.5 = 150%, etc.)
  619. */
  620. type: Number,
  621. defaultValue: 1.0,
  622. min: 0.5,
  623. max: 3.0,
  624. },
  625. services: {
  626. /**
  627. * services field of the user
  628. */
  629. type: Object,
  630. optional: true,
  631. blackbox: true,
  632. },
  633. heartbeat: {
  634. /**
  635. * last time the user has been seen
  636. */
  637. type: Date,
  638. optional: true,
  639. },
  640. isAdmin: {
  641. /**
  642. * is the user an admin of the board?
  643. */
  644. type: Boolean,
  645. optional: true,
  646. },
  647. createdThroughApi: {
  648. /**
  649. * was the user created through the API?
  650. */
  651. type: Boolean,
  652. optional: true,
  653. },
  654. loginDisabled: {
  655. /**
  656. * loginDisabled field of the user
  657. */
  658. type: Boolean,
  659. optional: true,
  660. },
  661. authenticationMethod: {
  662. /**
  663. * authentication method of the user
  664. */
  665. type: String,
  666. optional: false,
  667. defaultValue: 'password',
  668. },
  669. sessionData: {
  670. /**
  671. * profile settings
  672. */
  673. type: Object,
  674. optional: true,
  675. // eslint-disable-next-line consistent-return
  676. autoValue() {
  677. if (this.isInsert && !this.isSet) {
  678. return {};
  679. }
  680. },
  681. },
  682. 'sessionData.totalHits': {
  683. /**
  684. * Total hits from last searchquery['members.userId'] = Meteor.userId();
  685. * last hit that was returned
  686. */
  687. type: Number,
  688. optional: true,
  689. },
  690. importUsernames: {
  691. /**
  692. * username for imported
  693. */
  694. type: [String],
  695. optional: true,
  696. },
  697. lastConnectionDate: {
  698. type: Date,
  699. optional: true,
  700. },
  701. }),
  702. );
  703. // Security helpers for user updates
  704. export const USER_UPDATE_ALLOWED_EXACT = ['username', 'profile'];
  705. export const USER_UPDATE_ALLOWED_PREFIXES = ['profile.'];
  706. export const USER_UPDATE_FORBIDDEN_PREFIXES = [
  707. 'services',
  708. 'emails',
  709. 'roles',
  710. 'isAdmin',
  711. 'createdThroughApi',
  712. 'orgs',
  713. 'teams',
  714. 'loginDisabled',
  715. 'authenticationMethod',
  716. 'sessionData',
  717. ];
  718. export function isUserUpdateAllowed(fields) {
  719. return fields.every((f) =>
  720. USER_UPDATE_ALLOWED_EXACT.includes(f) || USER_UPDATE_ALLOWED_PREFIXES.some((p) => f.startsWith(p))
  721. );
  722. }
  723. export function hasForbiddenUserUpdateField(fields) {
  724. return fields.some((f) => USER_UPDATE_FORBIDDEN_PREFIXES.some((p) => f === p || f.startsWith(p + '.')));
  725. }
  726. Users.allow({
  727. update(userId, doc, fields /*, modifier */) {
  728. // Only the owner can update, and only for allowed fields
  729. if (!userId || doc._id !== userId) return false;
  730. if (!Array.isArray(fields) || fields.length === 0) return false;
  731. // Disallow if any forbidden field present
  732. if (hasForbiddenUserUpdateField(fields)) return false;
  733. // Allow only username and profile.*
  734. return isUserUpdateAllowed(fields);
  735. },
  736. remove(userId, doc) {
  737. // Disable direct client-side user removal for security
  738. // All user removal should go through the secure server method 'removeUser'
  739. // This prevents IDOR vulnerabilities and ensures proper authorization checks
  740. return false;
  741. },
  742. fetch: [],
  743. });
  744. // Deny any attempts to touch forbidden fields from client updates
  745. Users.deny({
  746. update(userId, doc, fields /*, modifier */) {
  747. return hasForbiddenUserUpdateField(fields);
  748. },
  749. fetch: [],
  750. });
  751. // Custom MongoDB engine that enforces field restrictions
  752. // TODO: Re-enable when easy:search compatibility is fixed
  753. // class SecureMongoDBEngine extends MongoDBEngine {
  754. // getSearchCursor(searchObject, options) {
  755. // // Always enforce field projection to prevent data leakage
  756. // const secureProjection = {
  757. // _id: 1,
  758. // username: 1,
  759. // 'profile.fullname': 1,
  760. // 'profile.avatarUrl': 1,
  761. // };
  762. // // Override any projection passed in options
  763. // const secureOptions = {
  764. // ...options,
  765. // projection: secureProjection,
  766. // };
  767. // return super.getSearchCursor(searchObject, secureOptions);
  768. // }
  769. // }
  770. // Search a user in the complete server database by its name, username or emails adress. This
  771. // is used for instance to add a new user to a board.
  772. // TODO: Fix easy:search compatibility issue - temporarily disabled
  773. // UserSearchIndex = new Index({
  774. // collection: Users,
  775. // fields: ['username', 'profile.fullname', 'profile.avatarUrl'],
  776. // engine: new MongoDBEngine(),
  777. // });
  778. // Temporary fallback - create a simple search index object
  779. UserSearchIndex = {
  780. search: function(query, options) {
  781. // Simple fallback search using MongoDB find
  782. const searchRegex = new RegExp(query, 'i');
  783. return Users.find({
  784. $or: [
  785. { username: searchRegex },
  786. { 'profile.fullname': searchRegex }
  787. ]
  788. }, {
  789. fields: {
  790. _id: 1,
  791. username: 1,
  792. 'profile.fullname': 1,
  793. 'profile.avatarUrl': 1
  794. },
  795. limit: options?.limit || 20
  796. });
  797. }
  798. };
  799. Users.safeFields = {
  800. _id: 1,
  801. username: 1,
  802. 'profile.fullname': 1,
  803. 'profile.avatarUrl': 1,
  804. 'profile.initials': 1,
  805. 'profile.zoomLevel': 1,
  806. 'profile.mobileMode': 1,
  807. 'profile.GreyIcons': 1,
  808. orgs: 1,
  809. teams: 1,
  810. authenticationMethod: 1,
  811. lastConnectionDate: 1,
  812. };
  813. if (Meteor.isClient) {
  814. Users.helpers({
  815. isBoardMember() {
  816. const board = Utils.getCurrentBoard();
  817. return board && board.hasMember(this._id);
  818. },
  819. isNotNoComments() {
  820. const board = Utils.getCurrentBoard();
  821. return (
  822. board && board.hasMember(this._id) && !board.hasNoComments(this._id)
  823. );
  824. },
  825. isNoComments() {
  826. const board = Utils.getCurrentBoard();
  827. return board && board.hasNoComments(this._id);
  828. },
  829. isNotCommentOnly() {
  830. const board = Utils.getCurrentBoard();
  831. return (
  832. board && board.hasMember(this._id) && !board.hasCommentOnly(this._id)
  833. );
  834. },
  835. isCommentOnly() {
  836. const board = Utils.getCurrentBoard();
  837. return board && board.hasCommentOnly(this._id);
  838. },
  839. isNotWorker() {
  840. const board = Utils.getCurrentBoard();
  841. return board && board.hasMember(this._id) && !board.hasWorker(this._id);
  842. },
  843. isWorker() {
  844. const board = Utils.getCurrentBoard();
  845. return board && board.hasWorker(this._id);
  846. },
  847. isBoardAdmin(boardId) {
  848. let board;
  849. if (boardId) {
  850. board = ReactiveCache.getBoard(boardId);
  851. } else {
  852. board = Utils.getCurrentBoard();
  853. }
  854. return board && board.hasAdmin(this._id);
  855. },
  856. });
  857. }
  858. Users.parseImportUsernames = (usernamesString) => {
  859. return usernamesString.trim().split(new RegExp('\\s*[,;]\\s*'));
  860. };
  861. Users.helpers({
  862. importUsernamesString() {
  863. if (this.importUsernames) {
  864. return this.importUsernames.join(', ');
  865. }
  866. return '';
  867. },
  868. teamIds() {
  869. if (this.teams) {
  870. // TODO: Should the Team collection be queried to determine if the team isActive?
  871. return this.teams.map((team) => {
  872. return team.teamId;
  873. });
  874. }
  875. return [];
  876. },
  877. orgIds() {
  878. if (this.orgs) {
  879. // TODO: Should the Org collection be queried to determine if the organization isActive?
  880. return this.orgs.map((org) => {
  881. return org.orgId;
  882. });
  883. }
  884. return [];
  885. },
  886. orgsUserBelongs() {
  887. if (this.orgs) {
  888. return this.orgs
  889. .map(function (org) {
  890. return org.orgDisplayName;
  891. })
  892. .sort()
  893. .join(',');
  894. }
  895. return '';
  896. },
  897. orgIdsUserBelongs() {
  898. let ret = '';
  899. if (this.orgs) {
  900. ret = this.orgs.map(org => org.orgId).join(',');
  901. }
  902. return ret;
  903. },
  904. teamsUserBelongs() {
  905. if (this.teams) {
  906. return this.teams
  907. .map(function (team) {
  908. return team.teamDisplayName;
  909. })
  910. .sort()
  911. .join(',');
  912. }
  913. return '';
  914. },
  915. teamIdsUserBelongs() {
  916. let ret = '';
  917. if (this.teams) {
  918. ret = this.teams.map(team => team.teamId).join(',');
  919. }
  920. return ret;
  921. },
  922. boards() {
  923. // Fetch unsorted; sorting is per-user via profile.boardSortIndex
  924. return Boards.userBoards(this._id, null, {}, {});
  925. },
  926. starredBoards() {
  927. const { starredBoards = [] } = this.profile || {};
  928. return Boards.userBoards(this._id, false, { _id: { $in: starredBoards } }, {});
  929. },
  930. hasStarred(boardId) {
  931. const { starredBoards = [] } = this.profile || {};
  932. return _.contains(starredBoards, boardId);
  933. },
  934. isAutoWidth(boardId) {
  935. const { autoWidthBoards = {} } = this.profile || {};
  936. return autoWidthBoards[boardId] === true;
  937. },
  938. invitedBoards() {
  939. const { invitedBoards = [] } = this.profile || {};
  940. return Boards.userBoards(this._id, false, { _id: { $in: invitedBoards } }, {});
  941. },
  942. isInvitedTo(boardId) {
  943. const { invitedBoards = [] } = this.profile || {};
  944. return _.contains(invitedBoards, boardId);
  945. },
  946. _getListSortBy() {
  947. const profile = this.profile || {};
  948. const sortBy = profile.listSortBy || defaultSortBy;
  949. const keyPattern = /^(-{0,1})(.*$)/;
  950. const ret = [];
  951. if (keyPattern.exec(sortBy)) {
  952. ret[0] = RegExp.$2;
  953. ret[1] = RegExp.$1 ? -1 : 1;
  954. }
  955. return ret;
  956. },
  957. /**
  958. * Get per-user board sort index for a board, or null when not set
  959. */
  960. getBoardSortIndex(boardId) {
  961. const mapping = (this.profile && this.profile.boardSortIndex) || {};
  962. const v = mapping[boardId];
  963. return typeof v === 'number' ? v : null;
  964. },
  965. /**
  966. * Sort an array of boards by per-user mapping; fallback to title asc
  967. */
  968. sortBoardsForUser(boardsArr) {
  969. const mapping = (this.profile && this.profile.boardSortIndex) || {};
  970. const arr = (boardsArr || []).slice();
  971. arr.sort((a, b) => {
  972. const ia = typeof mapping[a._id] === 'number' ? mapping[a._id] : Number.POSITIVE_INFINITY;
  973. const ib = typeof mapping[b._id] === 'number' ? mapping[b._id] : Number.POSITIVE_INFINITY;
  974. if (ia !== ib) return ia - ib;
  975. const ta = (a.title || '').toLowerCase();
  976. const tb = (b.title || '').toLowerCase();
  977. if (ta < tb) return -1;
  978. if (ta > tb) return 1;
  979. return 0;
  980. });
  981. return arr;
  982. },
  983. hasSortBy() {
  984. // if use doesn't have dragHandle, then we can let user to choose sort list by different order
  985. return !this.hasShowDesktopDragHandles();
  986. },
  987. getListSortBy() {
  988. return this._getListSortBy()[0];
  989. },
  990. getListSortTypes() {
  991. return allowedSortValues;
  992. },
  993. getListSortByDirection() {
  994. return this._getListSortBy()[1];
  995. },
  996. getListWidths() {
  997. const { listWidths = {}, } = this.profile || {};
  998. return listWidths;
  999. },
  1000. getListWidth(boardId, listId) {
  1001. const listWidths = this.getListWidths();
  1002. if (listWidths[boardId] && listWidths[boardId][listId]) {
  1003. return listWidths[boardId][listId];
  1004. } else {
  1005. return 270; //TODO(mark-i-m): default?
  1006. }
  1007. },
  1008. getListConstraints() {
  1009. const { listConstraints = {} } = this.profile || {};
  1010. return listConstraints;
  1011. },
  1012. getListConstraint(boardId, listId) {
  1013. const listConstraints = this.getListConstraints();
  1014. if (listConstraints[boardId] && listConstraints[boardId][listId]) {
  1015. return listConstraints[boardId][listId];
  1016. } else {
  1017. return 550;
  1018. }
  1019. },
  1020. getSwimlaneHeights() {
  1021. const { swimlaneHeights = {} } = this.profile || {};
  1022. return swimlaneHeights;
  1023. },
  1024. getSwimlaneHeight(boardId, listId) {
  1025. const swimlaneHeights = this.getSwimlaneHeights();
  1026. if (swimlaneHeights[boardId] && swimlaneHeights[boardId][listId]) {
  1027. return swimlaneHeights[boardId][listId];
  1028. } else {
  1029. return -1;
  1030. }
  1031. },
  1032. getSwimlaneHeightFromStorage(boardId, swimlaneId) {
  1033. // For logged-in users, get from profile
  1034. if (this._id) {
  1035. return this.getSwimlaneHeight(boardId, swimlaneId);
  1036. }
  1037. // For non-logged-in users, get from localStorage
  1038. try {
  1039. const stored = localStorage.getItem('wekan-swimlane-heights');
  1040. if (stored) {
  1041. const heights = JSON.parse(stored);
  1042. if (heights[boardId] && heights[boardId][swimlaneId]) {
  1043. return heights[boardId][swimlaneId];
  1044. }
  1045. }
  1046. } catch (e) {
  1047. console.warn('Error reading swimlane heights from localStorage:', e);
  1048. }
  1049. return -1;
  1050. },
  1051. setSwimlaneHeightToStorage(boardId, swimlaneId, height) {
  1052. // For logged-in users, save to profile
  1053. if (this._id) {
  1054. return this.setSwimlaneHeight(boardId, swimlaneId, height);
  1055. }
  1056. // For non-logged-in users, save to localStorage
  1057. try {
  1058. const stored = localStorage.getItem('wekan-swimlane-heights');
  1059. let heights = stored ? JSON.parse(stored) : {};
  1060. if (!heights[boardId]) {
  1061. heights[boardId] = {};
  1062. }
  1063. heights[boardId][swimlaneId] = height;
  1064. localStorage.setItem('wekan-swimlane-heights', JSON.stringify(heights));
  1065. return true;
  1066. } catch (e) {
  1067. console.warn('Error saving swimlane height to localStorage:', e);
  1068. return false;
  1069. }
  1070. },
  1071. /** returns all confirmed move and copy dialog field values
  1072. * <li> the board, swimlane and list id is stored for each board
  1073. */
  1074. getMoveAndCopyDialogOptions() {
  1075. let _ret = {};
  1076. if (this.profile && this.profile.moveAndCopyDialog) {
  1077. _ret = this.profile.moveAndCopyDialog;
  1078. }
  1079. return _ret;
  1080. },
  1081. /** returns all confirmed move checklist dialog field values
  1082. * <li> the board, swimlane, list and card id is stored for each board
  1083. */
  1084. getMoveChecklistDialogOptions() {
  1085. let _ret = {};
  1086. if (this.profile && this.profile.moveChecklistDialog) {
  1087. _ret = this.profile.moveChecklistDialog;
  1088. }
  1089. return _ret;
  1090. },
  1091. /** returns all confirmed copy checklist dialog field values
  1092. * <li> the board, swimlane, list and card id is stored for each board
  1093. */
  1094. getCopyChecklistDialogOptions() {
  1095. let _ret = {};
  1096. if (this.profile && this.profile.copyChecklistDialog) {
  1097. _ret = this.profile.copyChecklistDialog;
  1098. }
  1099. return _ret;
  1100. },
  1101. hasTag(tag) {
  1102. const { tags = [] } = this.profile || {};
  1103. return _.contains(tags, tag);
  1104. },
  1105. hasNotification(activityId) {
  1106. const { notifications = [] } = this.profile || {};
  1107. return _.contains(notifications, activityId);
  1108. },
  1109. notifications() {
  1110. const { notifications = [] } = this.profile || {};
  1111. for (const index in notifications) {
  1112. if (!notifications.hasOwnProperty(index)) continue;
  1113. const notification = notifications[index];
  1114. // this preserves their db sort order for editing
  1115. notification.dbIndex = index;
  1116. if (!notification.activityObj && typeof(notification.activity) === 'string') {
  1117. notification.activityObj = ReactiveMiniMongoIndex.getActivityWithId(notification.activity);
  1118. }
  1119. }
  1120. // newest first. don't use reverse() because it changes the array inplace, so sometimes the array is reversed twice and oldest items at top again
  1121. const ret = notifications.toReversed();
  1122. return ret;
  1123. },
  1124. hasShowDesktopDragHandles() {
  1125. const profile = this.profile || {};
  1126. return profile.showDesktopDragHandles || false;
  1127. },
  1128. hasGreyIcons() {
  1129. const profile = this.profile || {};
  1130. return profile.GreyIcons || false;
  1131. },
  1132. hasCustomFieldsGrid() {
  1133. const profile = this.profile || {};
  1134. return profile.customFieldsGrid || false;
  1135. },
  1136. hasCardMaximized() {
  1137. const profile = this.profile || {};
  1138. return profile.cardMaximized || false;
  1139. },
  1140. hasHiddenMinicardLabelText() {
  1141. const profile = this.profile || {};
  1142. return profile.hiddenMinicardLabelText || false;
  1143. },
  1144. hasRescuedCardDescription() {
  1145. const profile = this.profile || {};
  1146. return profile.rescueCardDescription || false;
  1147. },
  1148. getEmailBuffer() {
  1149. const { emailBuffer = [] } = this.profile || {};
  1150. return emailBuffer;
  1151. },
  1152. getInitials() {
  1153. const profile = this.profile || {};
  1154. if (profile.initials) return profile.initials;
  1155. else if (profile.fullname) {
  1156. return profile.fullname
  1157. .split(/\s+/)
  1158. .reduce((memo, word) => {
  1159. return memo + word[0];
  1160. }, '')
  1161. .toUpperCase();
  1162. } else {
  1163. return this.username[0].toUpperCase();
  1164. }
  1165. },
  1166. getLimitToShowCardsCount() {
  1167. const profile = this.profile || {};
  1168. return profile.showCardsCountAt;
  1169. },
  1170. getName() {
  1171. const profile = this.profile || {};
  1172. return profile.fullname || this.username;
  1173. },
  1174. getLanguage() {
  1175. const profile = this.profile || {};
  1176. return profile.language || 'en';
  1177. },
  1178. getStartDayOfWeek() {
  1179. const profile = this.profile || {};
  1180. if (typeof profile.startDayOfWeek === 'undefined') {
  1181. // default is 'Monday' (1)
  1182. return 1;
  1183. }
  1184. return profile.startDayOfWeek;
  1185. },
  1186. getDateFormat() {
  1187. const profile = this.profile || {};
  1188. return profile.dateFormat || 'YYYY-MM-DD';
  1189. },
  1190. getTemplatesBoardId() {
  1191. return (this.profile || {}).templatesBoardId;
  1192. },
  1193. getTemplatesBoardSlug() {
  1194. //return (ReactiveCache.getBoard((this.profile || {}).templatesBoardId) || {}).slug;
  1195. return 'templates';
  1196. },
  1197. isKeyboardShortcuts() {
  1198. const { keyboardShortcuts = true } = this.profile || {};
  1199. return keyboardShortcuts;
  1200. },
  1201. isVerticalScrollbars() {
  1202. const { verticalScrollbars = true } = this.profile || {};
  1203. return verticalScrollbars;
  1204. },
  1205. isShowWeekOfYear() {
  1206. const { showWeekOfYear = true } = this.profile || {};
  1207. return showWeekOfYear;
  1208. },
  1209. remove() {
  1210. User.remove({
  1211. _id: this._id,
  1212. });
  1213. },
  1214. getListWidthFromStorage(boardId, listId) {
  1215. // For logged-in users, get from profile
  1216. if (this._id) {
  1217. return this.getListWidth(boardId, listId);
  1218. }
  1219. // For non-logged-in users, get from validated localStorage
  1220. if (typeof localStorage !== 'undefined' && typeof getValidatedLocalStorageData === 'function') {
  1221. try {
  1222. const widths = getValidatedLocalStorageData('wekan-list-widths', validators.listWidths);
  1223. if (widths[boardId] && widths[boardId][listId]) {
  1224. const width = widths[boardId][listId];
  1225. // Validate it's a valid number
  1226. if (validators.isValidNumber(width, 100, 1000)) {
  1227. return width;
  1228. }
  1229. }
  1230. } catch (e) {
  1231. console.warn('Error reading list widths from localStorage:', e);
  1232. }
  1233. }
  1234. return 270; // Return default width
  1235. },
  1236. setListWidthToStorage(boardId, listId, width) {
  1237. // For logged-in users, save to profile
  1238. if (this._id) {
  1239. return this.setListWidth(boardId, listId, width);
  1240. }
  1241. // Validate width before storing
  1242. if (!validators.isValidNumber(width, 100, 1000)) {
  1243. console.warn('Invalid list width:', width);
  1244. return false;
  1245. }
  1246. // For non-logged-in users, save to validated localStorage
  1247. if (typeof localStorage !== 'undefined' && typeof setValidatedLocalStorageData === 'function') {
  1248. try {
  1249. const widths = getValidatedLocalStorageData('wekan-list-widths', validators.listWidths);
  1250. if (!widths[boardId]) {
  1251. widths[boardId] = {};
  1252. }
  1253. widths[boardId][listId] = width;
  1254. return setValidatedLocalStorageData('wekan-list-widths', widths, validators.listWidths);
  1255. } catch (e) {
  1256. console.warn('Error saving list width to localStorage:', e);
  1257. return false;
  1258. }
  1259. }
  1260. return false;
  1261. },
  1262. getListConstraintFromStorage(boardId, listId) {
  1263. // For logged-in users, get from profile
  1264. if (this._id) {
  1265. return this.getListConstraint(boardId, listId);
  1266. }
  1267. // For non-logged-in users, get from localStorage
  1268. try {
  1269. const stored = localStorage.getItem('wekan-list-constraints');
  1270. if (stored) {
  1271. const constraints = JSON.parse(stored);
  1272. if (constraints[boardId] && constraints[boardId][listId]) {
  1273. return constraints[boardId][listId];
  1274. }
  1275. }
  1276. } catch (e) {
  1277. console.warn('Error reading list constraints from localStorage:', e);
  1278. }
  1279. return 550; // Return default constraint instead of -1
  1280. },
  1281. setListConstraintToStorage(boardId, listId, constraint) {
  1282. // For logged-in users, save to profile
  1283. if (this._id) {
  1284. return this.setListConstraint(boardId, listId, constraint);
  1285. }
  1286. // For non-logged-in users, save to localStorage
  1287. try {
  1288. const stored = localStorage.getItem('wekan-list-constraints');
  1289. let constraints = stored ? JSON.parse(stored) : {};
  1290. if (!constraints[boardId]) {
  1291. constraints[boardId] = {};
  1292. }
  1293. constraints[boardId][listId] = constraint;
  1294. localStorage.setItem('wekan-list-constraints', JSON.stringify(constraints));
  1295. return true;
  1296. } catch (e) {
  1297. console.warn('Error saving list constraint to localStorage:', e);
  1298. return false;
  1299. }
  1300. },
  1301. getSwimlaneHeightFromStorage(boardId, swimlaneId) {
  1302. // For logged-in users, get from profile
  1303. if (this._id) {
  1304. return this.getSwimlaneHeight(boardId, swimlaneId);
  1305. }
  1306. // For non-logged-in users, get from localStorage
  1307. try {
  1308. const stored = localStorage.getItem('wekan-swimlane-heights');
  1309. if (stored) {
  1310. const heights = JSON.parse(stored);
  1311. if (heights[boardId] && heights[boardId][swimlaneId]) {
  1312. return heights[boardId][swimlaneId];
  1313. }
  1314. }
  1315. } catch (e) {
  1316. console.warn('Error reading swimlane heights from localStorage:', e);
  1317. }
  1318. return -1; // Return -1 if not found
  1319. },
  1320. setSwimlaneHeightToStorage(boardId, swimlaneId, height) {
  1321. // For logged-in users, save to profile
  1322. if (this._id) {
  1323. return this.setSwimlaneHeight(boardId, swimlaneId, height);
  1324. }
  1325. // For non-logged-in users, save to localStorage
  1326. try {
  1327. const stored = localStorage.getItem('wekan-swimlane-heights');
  1328. let heights = stored ? JSON.parse(stored) : {};
  1329. if (!heights[boardId]) {
  1330. heights[boardId] = {};
  1331. }
  1332. heights[boardId][swimlaneId] = height;
  1333. localStorage.setItem('wekan-swimlane-heights', JSON.stringify(heights));
  1334. return true;
  1335. } catch (e) {
  1336. console.warn('Error saving swimlane height to localStorage:', e);
  1337. return false;
  1338. }
  1339. },
  1340. // Per-user collapsed state helpers for lists/swimlanes
  1341. getCollapsedList(boardId, listId) {
  1342. const { collapsedLists = {} } = this.profile || {};
  1343. if (collapsedLists[boardId] && typeof collapsedLists[boardId][listId] === 'boolean') {
  1344. return collapsedLists[boardId][listId];
  1345. }
  1346. return null;
  1347. },
  1348. getCollapsedSwimlane(boardId, swimlaneId) {
  1349. const { collapsedSwimlanes = {} } = this.profile || {};
  1350. if (collapsedSwimlanes[boardId] && typeof collapsedSwimlanes[boardId][swimlaneId] === 'boolean') {
  1351. return collapsedSwimlanes[boardId][swimlaneId];
  1352. }
  1353. return null;
  1354. },
  1355. setCollapsedListToStorage(boardId, listId, collapsed) {
  1356. // Logged-in users: save to profile
  1357. if (this._id) {
  1358. return this.setCollapsedList(boardId, listId, collapsed);
  1359. }
  1360. // Public users: save to cookie
  1361. try {
  1362. const name = 'wekan-collapsed-lists';
  1363. const stored = (typeof document !== 'undefined') ? document.cookie : '';
  1364. const cookies = stored.split(';').map(c => c.trim());
  1365. let json = '{}';
  1366. for (const c of cookies) {
  1367. if (c.startsWith(name + '=')) {
  1368. json = decodeURIComponent(c.substring(name.length + 1));
  1369. break;
  1370. }
  1371. }
  1372. let data = {};
  1373. try { data = JSON.parse(json || '{}'); } catch (e) { data = {}; }
  1374. if (!data[boardId]) data[boardId] = {};
  1375. data[boardId][listId] = !!collapsed;
  1376. const serialized = encodeURIComponent(JSON.stringify(data));
  1377. const maxAge = 60 * 60 * 24 * 365; // 1 year
  1378. document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`;
  1379. return true;
  1380. } catch (e) {
  1381. console.warn('Error saving collapsed list to cookie:', e);
  1382. return false;
  1383. }
  1384. },
  1385. getCollapsedListFromStorage(boardId, listId) {
  1386. // Logged-in users: read from profile
  1387. if (this._id) {
  1388. const v = this.getCollapsedList(boardId, listId);
  1389. return v;
  1390. }
  1391. // Public users: read from cookie
  1392. try {
  1393. const name = 'wekan-collapsed-lists';
  1394. const stored = (typeof document !== 'undefined') ? document.cookie : '';
  1395. const cookies = stored.split(';').map(c => c.trim());
  1396. let json = '{}';
  1397. for (const c of cookies) {
  1398. if (c.startsWith(name + '=')) {
  1399. json = decodeURIComponent(c.substring(name.length + 1));
  1400. break;
  1401. }
  1402. }
  1403. const data = JSON.parse(json || '{}');
  1404. if (data[boardId] && typeof data[boardId][listId] === 'boolean') {
  1405. return data[boardId][listId];
  1406. }
  1407. } catch (e) {
  1408. console.warn('Error reading collapsed list from cookie:', e);
  1409. }
  1410. return null;
  1411. },
  1412. setCollapsedSwimlaneToStorage(boardId, swimlaneId, collapsed) {
  1413. // Logged-in users: save to profile
  1414. if (this._id) {
  1415. return this.setCollapsedSwimlane(boardId, swimlaneId, collapsed);
  1416. }
  1417. // Public users: save to cookie
  1418. try {
  1419. const name = 'wekan-collapsed-swimlanes';
  1420. const stored = (typeof document !== 'undefined') ? document.cookie : '';
  1421. const cookies = stored.split(';').map(c => c.trim());
  1422. let json = '{}';
  1423. for (const c of cookies) {
  1424. if (c.startsWith(name + '=')) {
  1425. json = decodeURIComponent(c.substring(name.length + 1));
  1426. break;
  1427. }
  1428. }
  1429. let data = {};
  1430. try { data = JSON.parse(json || '{}'); } catch (e) { data = {}; }
  1431. if (!data[boardId]) data[boardId] = {};
  1432. data[boardId][swimlaneId] = !!collapsed;
  1433. const serialized = encodeURIComponent(JSON.stringify(data));
  1434. const maxAge = 60 * 60 * 24 * 365; // 1 year
  1435. document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`;
  1436. return true;
  1437. } catch (e) {
  1438. console.warn('Error saving collapsed swimlane to cookie:', e);
  1439. return false;
  1440. }
  1441. },
  1442. getCollapsedSwimlaneFromStorage(boardId, swimlaneId) {
  1443. // Logged-in users: read from profile
  1444. if (this._id) {
  1445. const v = this.getCollapsedSwimlane(boardId, swimlaneId);
  1446. return v;
  1447. }
  1448. // Public users: read from cookie
  1449. try {
  1450. const name = 'wekan-collapsed-swimlanes';
  1451. const stored = (typeof document !== 'undefined') ? document.cookie : '';
  1452. const cookies = stored.split(';').map(c => c.trim());
  1453. let json = '{}';
  1454. for (const c of cookies) {
  1455. if (c.startsWith(name + '=')) {
  1456. json = decodeURIComponent(c.substring(name.length + 1));
  1457. break;
  1458. }
  1459. }
  1460. const data = JSON.parse(json || '{}');
  1461. if (data[boardId] && typeof data[boardId][swimlaneId] === 'boolean') {
  1462. return data[boardId][swimlaneId];
  1463. }
  1464. } catch (e) {
  1465. console.warn('Error reading collapsed swimlane from cookie:', e);
  1466. }
  1467. return null;
  1468. },
  1469. });
  1470. Users.mutations({
  1471. /** set the confirmed board id/swimlane id/list id of a board
  1472. * @param boardId the current board id
  1473. * @param options an object with the confirmed field values
  1474. */
  1475. setMoveAndCopyDialogOption(boardId, options) {
  1476. let currentOptions = this.getMoveAndCopyDialogOptions();
  1477. currentOptions[boardId] = options;
  1478. return {
  1479. $set: {
  1480. 'profile.moveAndCopyDialog': currentOptions,
  1481. },
  1482. };
  1483. },
  1484. /** set the confirmed board id/swimlane id/list id/card id of a board (move checklist)
  1485. * @param boardId the current board id
  1486. * @param options an object with the confirmed field values
  1487. */
  1488. setMoveChecklistDialogOption(boardId, options) {
  1489. let currentOptions = this.getMoveChecklistDialogOptions();
  1490. currentOptions[boardId] = options;
  1491. return {
  1492. $set: {
  1493. 'profile.moveChecklistDialog': currentOptions,
  1494. },
  1495. };
  1496. },
  1497. /** set the confirmed board id/swimlane id/list id/card id of a board (copy checklist)
  1498. * @param boardId the current board id
  1499. * @param options an object with the confirmed field values
  1500. */
  1501. setCopyChecklistDialogOption(boardId, options) {
  1502. let currentOptions = this.getCopyChecklistDialogOptions();
  1503. currentOptions[boardId] = options;
  1504. return {
  1505. $set: {
  1506. 'profile.copyChecklistDialog': currentOptions,
  1507. },
  1508. };
  1509. },
  1510. toggleBoardStar(boardId) {
  1511. const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet';
  1512. return {
  1513. [queryKind]: {
  1514. 'profile.starredBoards': boardId,
  1515. },
  1516. };
  1517. },
  1518. /**
  1519. * Set per-user board sort index for a board
  1520. * Stored at profile.boardSortIndex[boardId] = sortIndex (Number)
  1521. */
  1522. setBoardSortIndex(boardId, sortIndex) {
  1523. const mapping = (this.profile && this.profile.boardSortIndex) || {};
  1524. mapping[boardId] = sortIndex;
  1525. return {
  1526. $set: {
  1527. 'profile.boardSortIndex': mapping,
  1528. },
  1529. };
  1530. },
  1531. toggleAutoWidth(boardId) {
  1532. const { autoWidthBoards = {} } = this.profile || {};
  1533. autoWidthBoards[boardId] = !autoWidthBoards[boardId];
  1534. return {
  1535. $set: {
  1536. 'profile.autoWidthBoards': autoWidthBoards,
  1537. },
  1538. };
  1539. },
  1540. toggleKeyboardShortcuts() {
  1541. const { keyboardShortcuts = true } = this.profile || {};
  1542. return {
  1543. $set: {
  1544. 'profile.keyboardShortcuts': !keyboardShortcuts,
  1545. },
  1546. };
  1547. },
  1548. toggleVerticalScrollbars() {
  1549. const { verticalScrollbars = true } = this.profile || {};
  1550. return {
  1551. $set: {
  1552. 'profile.verticalScrollbars': !verticalScrollbars,
  1553. },
  1554. };
  1555. },
  1556. toggleShowWeekOfYear() {
  1557. const { showWeekOfYear = true } = this.profile || {};
  1558. return {
  1559. $set: {
  1560. 'profile.showWeekOfYear': !showWeekOfYear,
  1561. },
  1562. };
  1563. },
  1564. addInvite(boardId) {
  1565. return {
  1566. $addToSet: {
  1567. 'profile.invitedBoards': boardId,
  1568. },
  1569. };
  1570. },
  1571. removeInvite(boardId) {
  1572. return {
  1573. $pull: {
  1574. 'profile.invitedBoards': boardId,
  1575. },
  1576. };
  1577. },
  1578. addTag(tag) {
  1579. return {
  1580. $addToSet: {
  1581. 'profile.tags': tag,
  1582. },
  1583. };
  1584. },
  1585. removeTag(tag) {
  1586. return {
  1587. $pull: {
  1588. 'profile.tags': tag,
  1589. },
  1590. };
  1591. },
  1592. toggleTag(tag) {
  1593. if (this.hasTag(tag)) this.removeTag(tag);
  1594. else this.addTag(tag);
  1595. },
  1596. setListSortBy(value) {
  1597. return {
  1598. $set: {
  1599. 'profile.listSortBy': value,
  1600. },
  1601. };
  1602. },
  1603. setName(value) {
  1604. return {
  1605. $set: {
  1606. 'profile.fullname': value,
  1607. },
  1608. };
  1609. },
  1610. toggleDesktopHandles(value = false) {
  1611. return {
  1612. $set: {
  1613. 'profile.showDesktopDragHandles': !value,
  1614. },
  1615. };
  1616. },
  1617. toggleFieldsGrid(value = false) {
  1618. return {
  1619. $set: {
  1620. 'profile.customFieldsGrid': !value,
  1621. },
  1622. };
  1623. },
  1624. toggleCardMaximized(value = false) {
  1625. return {
  1626. $set: {
  1627. 'profile.cardMaximized': !value,
  1628. },
  1629. };
  1630. },
  1631. toggleCardCollapsed(value = false) {
  1632. return {
  1633. $set: {
  1634. 'profile.cardCollapsed': !value,
  1635. },
  1636. };
  1637. },
  1638. toggleLabelText(value = false) {
  1639. return {
  1640. $set: {
  1641. 'profile.hiddenMinicardLabelText': !value,
  1642. },
  1643. };
  1644. },
  1645. toggleRescueCardDescription(value = false) {
  1646. return {
  1647. $set: {
  1648. 'profile.rescueCardDescription': !value,
  1649. },
  1650. };
  1651. },
  1652. toggleGreyIcons(value = false) {
  1653. return {
  1654. $set: {
  1655. 'profile.GreyIcons': !value,
  1656. },
  1657. };
  1658. },
  1659. addNotification(activityId) {
  1660. return {
  1661. $addToSet: {
  1662. 'profile.notifications': {
  1663. activity: activityId,
  1664. },
  1665. },
  1666. };
  1667. },
  1668. removeNotification(activityId) {
  1669. return {
  1670. $pull: {
  1671. 'profile.notifications': {
  1672. activity: activityId,
  1673. },
  1674. },
  1675. };
  1676. },
  1677. addEmailBuffer(text) {
  1678. return {
  1679. $addToSet: {
  1680. 'profile.emailBuffer': text,
  1681. },
  1682. };
  1683. },
  1684. clearEmailBuffer() {
  1685. return {
  1686. $set: {
  1687. 'profile.emailBuffer': [],
  1688. },
  1689. };
  1690. },
  1691. setAvatarUrl(avatarUrl) {
  1692. return {
  1693. $set: {
  1694. 'profile.avatarUrl': avatarUrl,
  1695. },
  1696. };
  1697. },
  1698. setShowCardsCountAt(limit) {
  1699. return {
  1700. $set: {
  1701. 'profile.showCardsCountAt': limit,
  1702. },
  1703. };
  1704. },
  1705. setStartDayOfWeek(startDay) {
  1706. return {
  1707. $set: {
  1708. 'profile.startDayOfWeek': startDay,
  1709. },
  1710. };
  1711. },
  1712. setDateFormat(dateFormat) {
  1713. return {
  1714. $set: {
  1715. 'profile.dateFormat': dateFormat,
  1716. },
  1717. };
  1718. },
  1719. setBoardView(view) {
  1720. return {
  1721. $set: {
  1722. 'profile.boardView': view,
  1723. },
  1724. };
  1725. },
  1726. setListWidth(boardId, listId, width) {
  1727. let currentWidths = this.getListWidths();
  1728. if (!currentWidths[boardId]) {
  1729. currentWidths[boardId] = {};
  1730. }
  1731. currentWidths[boardId][listId] = width;
  1732. return {
  1733. $set: {
  1734. 'profile.listWidths': currentWidths,
  1735. },
  1736. };
  1737. },
  1738. setListConstraint(boardId, listId, constraint) {
  1739. let currentConstraints = this.getListConstraints();
  1740. if (!currentConstraints[boardId]) {
  1741. currentConstraints[boardId] = {};
  1742. }
  1743. currentConstraints[boardId][listId] = constraint;
  1744. return {
  1745. $set: {
  1746. 'profile.listConstraints': currentConstraints,
  1747. },
  1748. };
  1749. },
  1750. setSwimlaneHeight(boardId, swimlaneId, height) {
  1751. let currentHeights = this.getSwimlaneHeights();
  1752. if (!currentHeights[boardId]) {
  1753. currentHeights[boardId] = {};
  1754. }
  1755. currentHeights[boardId][swimlaneId] = height;
  1756. return {
  1757. $set: {
  1758. 'profile.swimlaneHeights': currentHeights,
  1759. },
  1760. };
  1761. },
  1762. setCollapsedList(boardId, listId, collapsed) {
  1763. const current = (this.profile && this.profile.collapsedLists) || {};
  1764. if (!current[boardId]) current[boardId] = {};
  1765. current[boardId][listId] = !!collapsed;
  1766. return {
  1767. $set: {
  1768. 'profile.collapsedLists': current,
  1769. },
  1770. };
  1771. },
  1772. setCollapsedSwimlane(boardId, swimlaneId, collapsed) {
  1773. const current = (this.profile && this.profile.collapsedSwimlanes) || {};
  1774. if (!current[boardId]) current[boardId] = {};
  1775. current[boardId][swimlaneId] = !!collapsed;
  1776. return {
  1777. $set: {
  1778. 'profile.collapsedSwimlanes': current,
  1779. },
  1780. };
  1781. },
  1782. setZoomLevel(level) {
  1783. return {
  1784. $set: {
  1785. 'profile.zoomLevel': level,
  1786. },
  1787. };
  1788. },
  1789. setMobileMode(enabled) {
  1790. return {
  1791. $set: {
  1792. 'profile.mobileMode': enabled,
  1793. },
  1794. };
  1795. },
  1796. setCardZoom(level) {
  1797. return {
  1798. $set: {
  1799. 'profile.cardZoom': level,
  1800. },
  1801. };
  1802. },
  1803. });
  1804. Meteor.methods({
  1805. // Secure user removal method with proper authorization checks
  1806. removeUser(targetUserId) {
  1807. check(targetUserId, String);
  1808. const currentUserId = Meteor.userId();
  1809. if (!currentUserId) {
  1810. throw new Meteor.Error('not-authorized', 'User must be logged in');
  1811. }
  1812. const currentUser = ReactiveCache.getUser(currentUserId);
  1813. if (!currentUser) {
  1814. throw new Meteor.Error('not-authorized', 'Current user not found');
  1815. }
  1816. const targetUser = ReactiveCache.getUser(targetUserId);
  1817. if (!targetUser) {
  1818. throw new Meteor.Error('user-not-found', 'Target user not found');
  1819. }
  1820. // Check if user is trying to delete themselves
  1821. if (currentUserId === targetUserId) {
  1822. // User can delete themselves
  1823. Users.remove(targetUserId);
  1824. return { success: true, message: 'User deleted successfully' };
  1825. }
  1826. // Check if current user is admin
  1827. if (!currentUser.isAdmin) {
  1828. throw new Meteor.Error('not-authorized', 'Only administrators can delete other users');
  1829. }
  1830. // Check if target user is the last admin
  1831. const adminsNumber = ReactiveCache.getUsers({
  1832. isAdmin: true,
  1833. }).length;
  1834. if (adminsNumber === 1 && targetUser.isAdmin) {
  1835. throw new Meteor.Error('not-authorized', 'Cannot delete the last administrator');
  1836. }
  1837. // Admin can delete non-admin users
  1838. Users.remove(targetUserId);
  1839. return { success: true, message: 'User deleted successfully' };
  1840. },
  1841. setListSortBy(value) {
  1842. check(value, String);
  1843. ReactiveCache.getCurrentUser().setListSortBy(value);
  1844. },
  1845. toggleBoardStar(boardId) {
  1846. check(boardId, String);
  1847. if (!this.userId) {
  1848. throw new Meteor.Error('not-logged-in', 'User must be logged in');
  1849. }
  1850. const user = Users.findOne(this.userId);
  1851. if (!user) {
  1852. throw new Meteor.Error('user-not-found', 'User not found');
  1853. }
  1854. // Check if board is already starred
  1855. const starredBoards = (user.profile && user.profile.starredBoards) || [];
  1856. const isStarred = starredBoards.includes(boardId);
  1857. // Build update object
  1858. const updateObject = isStarred
  1859. ? { $pull: { 'profile.starredBoards': boardId } }
  1860. : { $addToSet: { 'profile.starredBoards': boardId } };
  1861. Users.update(this.userId, updateObject);
  1862. },
  1863. toggleGreyIcons(value) {
  1864. if (!this.userId) {
  1865. throw new Meteor.Error('not-logged-in', 'User must be logged in');
  1866. }
  1867. if (value !== undefined) check(value, Boolean);
  1868. const user = Users.findOne(this.userId);
  1869. if (!user) {
  1870. throw new Meteor.Error('user-not-found', 'User not found');
  1871. }
  1872. const current = (user.profile && user.profile.GreyIcons) || false;
  1873. const newValue = value !== undefined ? value : !current;
  1874. Users.update(this.userId, { $set: { 'profile.GreyIcons': newValue } });
  1875. return newValue;
  1876. },
  1877. toggleDesktopDragHandles() {
  1878. const user = ReactiveCache.getCurrentUser();
  1879. user.toggleDesktopHandles(user.hasShowDesktopDragHandles());
  1880. },
  1881. // Spaces: create a new space under parentId (or root when null)
  1882. createWorkspace({ parentId = null, name }) {
  1883. check(name, String);
  1884. if (!this.userId) throw new Meteor.Error('not-logged-in');
  1885. const user = Users.findOne(this.userId) || {};
  1886. const tree = (user.profile && user.profile.boardWorkspacesTree) ? EJSON.clone(user.profile.boardWorkspacesTree) : [];
  1887. const newNode = { id: Random.id(), name, children: [] };
  1888. if (!parentId) {
  1889. tree.push(newNode);
  1890. } else {
  1891. const insertInto = (nodes) => {
  1892. for (let n of nodes) {
  1893. if (n.id === parentId) {
  1894. n.children = n.children || [];
  1895. n.children.push(newNode);
  1896. return true;
  1897. }
  1898. if (n.children && n.children.length) {
  1899. if (insertInto(n.children)) return true;
  1900. }
  1901. }
  1902. return false;
  1903. };
  1904. insertInto(tree);
  1905. }
  1906. Users.update(this.userId, { $set: { 'profile.boardWorkspacesTree': tree } });
  1907. return newNode;
  1908. },
  1909. // Spaces: set entire tree (used for drag-drop reordering)
  1910. setWorkspacesTree(newTree) {
  1911. check(newTree, Array);
  1912. if (!this.userId) throw new Meteor.Error('not-logged-in');
  1913. Users.update(this.userId, { $set: { 'profile.boardWorkspacesTree': newTree } });
  1914. return true;
  1915. },
  1916. // Assign a board to a space
  1917. assignBoardToWorkspace(boardId, spaceId) {
  1918. check(boardId, String);
  1919. check(spaceId, String);
  1920. if (!this.userId) throw new Meteor.Error('not-logged-in');
  1921. const user = Users.findOne(this.userId);
  1922. const assignments = user.profile?.boardWorkspaceAssignments || {};
  1923. assignments[boardId] = spaceId;
  1924. Users.update(this.userId, {
  1925. $set: { 'profile.boardWorkspaceAssignments': assignments }
  1926. });
  1927. return true;
  1928. },
  1929. // Remove a board assignment (moves it back to Remaining)
  1930. unassignBoardFromWorkspace(boardId) {
  1931. check(boardId, String);
  1932. if (!this.userId) throw new Meteor.Error('not-logged-in');
  1933. const user = Users.findOne(this.userId);
  1934. const assignments = user.profile?.boardWorkspaceAssignments || {};
  1935. delete assignments[boardId];
  1936. Users.update(this.userId, {
  1937. $set: { 'profile.boardWorkspaceAssignments': assignments }
  1938. });
  1939. return true;
  1940. },
  1941. toggleHideCheckedItems() {
  1942. const user = ReactiveCache.getCurrentUser();
  1943. user.toggleHideCheckedItems();
  1944. },
  1945. toggleCustomFieldsGrid() {
  1946. const user = ReactiveCache.getCurrentUser();
  1947. user.toggleFieldsGrid(user.hasCustomFieldsGrid());
  1948. },
  1949. toggleCardMaximized() {
  1950. const user = ReactiveCache.getCurrentUser();
  1951. user.toggleCardMaximized(user.hasCardMaximized());
  1952. },
  1953. setCardCollapsed(value) {
  1954. check(value, Boolean);
  1955. if (!this.userId) throw new Meteor.Error('not-logged-in');
  1956. Users.update(this.userId, { $set: { 'profile.cardCollapsed': value } });
  1957. },
  1958. toggleMinicardLabelText() {
  1959. const user = ReactiveCache.getCurrentUser();
  1960. user.toggleLabelText(user.hasHiddenMinicardLabelText());
  1961. },
  1962. toggleRescueCardDescription() {
  1963. const user = ReactiveCache.getCurrentUser();
  1964. user.toggleRescueCardDescription(user.hasRescuedCardDescription());
  1965. },
  1966. changeLimitToShowCardsCount(limit) {
  1967. check(limit, Number);
  1968. ReactiveCache.getCurrentUser().setShowCardsCountAt(limit);
  1969. },
  1970. changeStartDayOfWeek(startDay) {
  1971. check(startDay, Number);
  1972. ReactiveCache.getCurrentUser().setStartDayOfWeek(startDay);
  1973. },
  1974. changeDateFormat(dateFormat) {
  1975. check(dateFormat, String);
  1976. ReactiveCache.getCurrentUser().setDateFormat(dateFormat);
  1977. },
  1978. applyListWidth(boardId, listId, width, constraint) {
  1979. check(boardId, String);
  1980. check(listId, String);
  1981. check(width, Number);
  1982. check(constraint, Number);
  1983. const user = ReactiveCache.getCurrentUser();
  1984. user.setListWidth(boardId, listId, width);
  1985. user.setListConstraint(boardId, listId, constraint);
  1986. },
  1987. setListCollapsedState(boardId, listId, collapsed) {
  1988. check(boardId, String);
  1989. check(listId, String);
  1990. check(collapsed, Boolean);
  1991. if (!this.userId) {
  1992. throw new Meteor.Error('not-logged-in', 'User must be logged in');
  1993. }
  1994. const user = Users.findOne(this.userId);
  1995. if (!user) {
  1996. throw new Meteor.Error('user-not-found', 'User not found');
  1997. }
  1998. const current = (user.profile && user.profile.collapsedLists) || {};
  1999. if (!current[boardId]) current[boardId] = {};
  2000. current[boardId][listId] = !!collapsed;
  2001. Users.update(this.userId, {
  2002. $set: {
  2003. 'profile.collapsedLists': current,
  2004. },
  2005. });
  2006. },
  2007. applySwimlaneHeight(boardId, swimlaneId, height) {
  2008. check(boardId, String);
  2009. check(swimlaneId, String);
  2010. check(height, Number);
  2011. const user = ReactiveCache.getCurrentUser();
  2012. user.setSwimlaneHeight(boardId, swimlaneId, height);
  2013. },
  2014. setSwimlaneCollapsedState(boardId, swimlaneId, collapsed) {
  2015. check(boardId, String);
  2016. check(swimlaneId, String);
  2017. check(collapsed, Boolean);
  2018. if (!this.userId) {
  2019. throw new Meteor.Error('not-logged-in', 'User must be logged in');
  2020. }
  2021. const user = Users.findOne(this.userId);
  2022. if (!user) {
  2023. throw new Meteor.Error('user-not-found', 'User not found');
  2024. }
  2025. const current = (user.profile && user.profile.collapsedSwimlanes) || {};
  2026. if (!current[boardId]) current[boardId] = {};
  2027. current[boardId][swimlaneId] = !!collapsed;
  2028. Users.update(this.userId, {
  2029. $set: {
  2030. 'profile.collapsedSwimlanes': current,
  2031. },
  2032. });
  2033. },
  2034. applySwimlaneHeightToStorage(boardId, swimlaneId, height) {
  2035. check(boardId, String);
  2036. check(swimlaneId, String);
  2037. check(height, Number);
  2038. const user = ReactiveCache.getCurrentUser();
  2039. if (user) {
  2040. user.setSwimlaneHeightToStorage(boardId, swimlaneId, height);
  2041. }
  2042. // For non-logged-in users, the client-side code will handle localStorage
  2043. },
  2044. applyListWidthToStorage(boardId, listId, width, constraint) {
  2045. check(boardId, String);
  2046. check(listId, String);
  2047. check(width, Number);
  2048. check(constraint, Number);
  2049. const user = ReactiveCache.getCurrentUser();
  2050. if (user) {
  2051. user.setListWidthToStorage(boardId, listId, width);
  2052. user.setListConstraintToStorage(boardId, listId, constraint);
  2053. }
  2054. // For non-logged-in users, the client-side code will handle localStorage
  2055. },
  2056. setZoomLevel(level) {
  2057. check(level, Number);
  2058. const user = ReactiveCache.getCurrentUser();
  2059. user.setZoomLevel(level);
  2060. },
  2061. setMobileMode(enabled) {
  2062. check(enabled, Boolean);
  2063. const user = ReactiveCache.getCurrentUser();
  2064. user.setMobileMode(enabled);
  2065. },
  2066. setBoardView(view) {
  2067. check(view, String);
  2068. const user = ReactiveCache.getCurrentUser();
  2069. if (!user) {
  2070. throw new Meteor.Error('not-authorized', 'Must be logged in');
  2071. }
  2072. user.setBoardView(view);
  2073. },
  2074. });
  2075. if (Meteor.isServer) {
  2076. Meteor.methods({
  2077. setCreateUser(
  2078. fullname,
  2079. username,
  2080. initials,
  2081. password,
  2082. isAdmin,
  2083. isActive,
  2084. email,
  2085. importUsernames,
  2086. userOrgsArray,
  2087. userTeamsArray,
  2088. ) {
  2089. check(fullname, String);
  2090. check(username, String);
  2091. check(initials, String);
  2092. check(password, String);
  2093. check(isAdmin, String);
  2094. check(isActive, String);
  2095. check(email, String);
  2096. check(importUsernames, Array);
  2097. check(userOrgsArray, Array);
  2098. check(userTeamsArray, Array);
  2099. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2100. // Thanks to mc-marcy and xet7 !
  2101. if (fullname.includes('/') ||
  2102. username.includes('/') ||
  2103. email.includes('/') ||
  2104. initials.includes('/')) {
  2105. return false;
  2106. }
  2107. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2108. const nUsersWithUsername = ReactiveCache.getUsers({
  2109. username,
  2110. }).length;
  2111. const nUsersWithEmail = ReactiveCache.getUsers({
  2112. email,
  2113. }).length;
  2114. if (nUsersWithUsername > 0) {
  2115. throw new Meteor.Error('username-already-taken');
  2116. } else if (nUsersWithEmail > 0) {
  2117. throw new Meteor.Error('email-already-taken');
  2118. } else {
  2119. Accounts.createUser({
  2120. username,
  2121. password,
  2122. isAdmin,
  2123. isActive,
  2124. email: email.toLowerCase(),
  2125. from: 'admin',
  2126. });
  2127. const user =
  2128. ReactiveCache.getUser(username) ||
  2129. ReactiveCache.getUser({ username });
  2130. if (user) {
  2131. Users.update(user._id, {
  2132. $set: {
  2133. 'profile.fullname': fullname,
  2134. importUsernames,
  2135. 'profile.initials': initials,
  2136. orgs: userOrgsArray,
  2137. teams: userTeamsArray,
  2138. },
  2139. });
  2140. }
  2141. }
  2142. }
  2143. },
  2144. setUsername(username, userId) {
  2145. check(username, String);
  2146. check(userId, String);
  2147. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2148. // Thanks to mc-marcy and xet7 !
  2149. if (username.includes('/') ||
  2150. userId.includes('/')) {
  2151. return false;
  2152. }
  2153. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2154. const nUsersWithUsername = ReactiveCache.getUsers({
  2155. username,
  2156. }).length;
  2157. if (nUsersWithUsername > 0) {
  2158. throw new Meteor.Error('username-already-taken');
  2159. } else {
  2160. Users.update(userId, {
  2161. $set: {
  2162. username,
  2163. },
  2164. });
  2165. }
  2166. }
  2167. },
  2168. setEmail(email, userId) {
  2169. check(email, String);
  2170. check(username, String);
  2171. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2172. // Thanks to mc-marcy and xet7 !
  2173. if (username.includes('/') ||
  2174. email.includes('/')) {
  2175. return false;
  2176. }
  2177. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2178. if (Array.isArray(email)) {
  2179. email = email.shift();
  2180. }
  2181. const existingUser = ReactiveCache.getUser(
  2182. {
  2183. 'emails.address': email,
  2184. },
  2185. {
  2186. fields: {
  2187. _id: 1,
  2188. },
  2189. },
  2190. );
  2191. if (existingUser) {
  2192. throw new Meteor.Error('email-already-taken');
  2193. } else {
  2194. Users.update(userId, {
  2195. $set: {
  2196. emails: [
  2197. {
  2198. address: email,
  2199. verified: false,
  2200. },
  2201. ],
  2202. },
  2203. });
  2204. }
  2205. }
  2206. },
  2207. setUsernameAndEmail(username, email, userId) {
  2208. check(username, String);
  2209. check(email, String);
  2210. check(userId, String);
  2211. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2212. // Thanks to mc-marcy and xet7 !
  2213. if (username.includes('/') ||
  2214. email.includes('/') ||
  2215. userId.includes('/')) {
  2216. return false;
  2217. }
  2218. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2219. if (Array.isArray(email)) {
  2220. email = email.shift();
  2221. }
  2222. Meteor.call('setUsername', username, userId);
  2223. Meteor.call('setEmail', email, userId);
  2224. }
  2225. },
  2226. setPassword(newPassword, userId) {
  2227. check(userId, String);
  2228. check(newPassword, String);
  2229. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2230. Accounts.setPassword(userId, newPassword);
  2231. }
  2232. },
  2233. setEmailVerified(email, verified, userId) {
  2234. check(email, String);
  2235. check(verified, Boolean);
  2236. check(userId, String);
  2237. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2238. // Thanks to mc-marcy and xet7 !
  2239. if (email.includes('/') ||
  2240. userId.includes('/')) {
  2241. return false;
  2242. }
  2243. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2244. Users.update(userId, {
  2245. $set: {
  2246. emails: [
  2247. {
  2248. address: email,
  2249. verified,
  2250. },
  2251. ],
  2252. },
  2253. });
  2254. }
  2255. },
  2256. setInitials(initials, userId) {
  2257. check(initials, String);
  2258. check(userId, String);
  2259. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2260. // Thanks to mc-marcy and xet7 !
  2261. if (initials.includes('/') ||
  2262. userId.includes('/')) {
  2263. return false;
  2264. }
  2265. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2266. Users.update(userId, {
  2267. $set: {
  2268. 'profile.initials': initials,
  2269. },
  2270. });
  2271. }
  2272. },
  2273. // we accept userId, username, email
  2274. inviteUserToBoard(username, boardId) {
  2275. check(username, String);
  2276. check(boardId, String);
  2277. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2278. // Thanks to mc-marcy and xet7 !
  2279. if (username.includes('/') ||
  2280. boardId.includes('/')) {
  2281. return false;
  2282. }
  2283. const inviter = ReactiveCache.getCurrentUser();
  2284. const board = ReactiveCache.getBoard(boardId);
  2285. const allowInvite =
  2286. inviter &&
  2287. board &&
  2288. board.members &&
  2289. _.contains(_.pluck(board.members, 'userId'), inviter._id) &&
  2290. _.where(board.members, {
  2291. userId: inviter._id,
  2292. })[0].isActive;
  2293. // GitHub issue 2060
  2294. //_.where(board.members, { userId: inviter._id })[0].isAdmin;
  2295. if (!allowInvite) throw new Meteor.Error('error-board-notAMember');
  2296. this.unblock();
  2297. const posAt = username.indexOf('@');
  2298. let user = null;
  2299. if (posAt >= 0) {
  2300. user = ReactiveCache.getUser({
  2301. emails: {
  2302. $elemMatch: {
  2303. address: username,
  2304. },
  2305. },
  2306. });
  2307. } else {
  2308. user =
  2309. ReactiveCache.getUser(username) ||
  2310. ReactiveCache.getUser({ username });
  2311. }
  2312. if (user) {
  2313. if (user._id === inviter._id)
  2314. throw new Meteor.Error('error-user-notAllowSelf');
  2315. } else {
  2316. if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist');
  2317. if (ReactiveCache.getCurrentSetting().disableRegistration) {
  2318. throw new Meteor.Error('error-user-notCreated');
  2319. }
  2320. // Set in lowercase email before creating account
  2321. const email = username.toLowerCase();
  2322. username = email.substring(0, posAt);
  2323. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2324. // Thanks to mc-marcy and xet7 !
  2325. if (username.includes('/') ||
  2326. email.includes('/')) {
  2327. return false;
  2328. }
  2329. const newUserId = Accounts.createUser({
  2330. username,
  2331. email,
  2332. });
  2333. if (!newUserId) throw new Meteor.Error('error-user-notCreated');
  2334. // assume new user speak same language with inviter
  2335. if (inviter.profile && inviter.profile.language) {
  2336. Users.update(newUserId, {
  2337. $set: {
  2338. 'profile.language': inviter.profile.language,
  2339. },
  2340. });
  2341. }
  2342. Accounts.sendEnrollmentEmail(newUserId);
  2343. user = ReactiveCache.getUser(newUserId);
  2344. }
  2345. board.addMember(user._id);
  2346. user.addInvite(boardId);
  2347. //Check if there is a subtasks board
  2348. if (board.subtasksDefaultBoardId) {
  2349. const subBoard = ReactiveCache.getBoard(board.subtasksDefaultBoardId);
  2350. //If there is, also add user to that board
  2351. if (subBoard) {
  2352. subBoard.addMember(user._id);
  2353. user.addInvite(subBoard._id);
  2354. }
  2355. } try {
  2356. const fullName =
  2357. inviter.profile !== undefined &&
  2358. inviter.profile.fullname !== undefined
  2359. ? inviter.profile.fullname
  2360. : '';
  2361. const userFullName =
  2362. user.profile !== undefined && user.profile.fullname !== undefined
  2363. ? user.profile.fullname
  2364. : '';
  2365. const params = {
  2366. user:
  2367. userFullName != ''
  2368. ? userFullName + ' (' + user.username + ' )'
  2369. : user.username,
  2370. inviter:
  2371. fullName != ''
  2372. ? fullName + ' (' + inviter.username + ' )'
  2373. : inviter.username,
  2374. board: board.title,
  2375. url: board.absoluteUrl(),
  2376. };
  2377. // Get the recipient user's language preference for the email
  2378. const lang = user.getLanguage();
  2379. // Add code to send invitation with EmailLocalization
  2380. if (typeof EmailLocalization !== 'undefined') {
  2381. EmailLocalization.sendEmail({
  2382. to: user.emails[0].address,
  2383. from: Accounts.emailTemplates.from,
  2384. subject: 'email-invite-subject',
  2385. text: 'email-invite-text',
  2386. params: params,
  2387. language: lang,
  2388. userId: user._id
  2389. });
  2390. } else {
  2391. // Fallback if EmailLocalization is not available
  2392. Email.send({
  2393. to: user.emails[0].address,
  2394. from: Accounts.emailTemplates.from,
  2395. subject: TAPi18n.__('email-invite-subject', params, lang),
  2396. text: TAPi18n.__('email-invite-text', params, lang),
  2397. });
  2398. }
  2399. } catch (e) {
  2400. throw new Meteor.Error('email-fail', e.message);
  2401. }
  2402. return {
  2403. username: user.username,
  2404. email: user.emails[0].address,
  2405. };
  2406. },
  2407. impersonate(userId) {
  2408. check(userId, String);
  2409. if (!ReactiveCache.getUser(userId))
  2410. throw new Meteor.Error(404, 'User not found');
  2411. if (!ReactiveCache.getCurrentUser().isAdmin)
  2412. throw new Meteor.Error(403, 'Permission denied');
  2413. ImpersonatedUsers.insert({
  2414. adminId: ReactiveCache.getCurrentUser()._id,
  2415. userId: userId,
  2416. reason: 'clickedImpersonate',
  2417. });
  2418. this.setUserId(userId);
  2419. },
  2420. isImpersonated(userId) {
  2421. check(userId, String);
  2422. const isImpersonated = ReactiveCache.getImpersonatedUser({ userId: userId });
  2423. return isImpersonated;
  2424. },
  2425. setUsersTeamsTeamDisplayName(teamId, teamDisplayName) {
  2426. check(teamId, String);
  2427. check(teamDisplayName, String);
  2428. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2429. ReactiveCache.getUsers({
  2430. teams: {
  2431. $elemMatch: { teamId: teamId },
  2432. },
  2433. }).forEach((user) => {
  2434. Users.update(
  2435. {
  2436. _id: user._id,
  2437. teams: {
  2438. $elemMatch: { teamId: teamId },
  2439. },
  2440. },
  2441. {
  2442. $set: {
  2443. 'teams.$.teamDisplayName': teamDisplayName,
  2444. },
  2445. },
  2446. );
  2447. });
  2448. }
  2449. },
  2450. setUsersOrgsOrgDisplayName(orgId, orgDisplayName) {
  2451. check(orgId, String);
  2452. check(orgDisplayName, String);
  2453. if (ReactiveCache.getCurrentUser()?.isAdmin) {
  2454. ReactiveCache.getUsers({
  2455. orgs: {
  2456. $elemMatch: { orgId: orgId },
  2457. },
  2458. }).forEach((user) => {
  2459. Users.update(
  2460. {
  2461. _id: user._id,
  2462. orgs: {
  2463. $elemMatch: { orgId: orgId },
  2464. },
  2465. },
  2466. {
  2467. $set: {
  2468. 'orgs.$.orgDisplayName': orgDisplayName,
  2469. },
  2470. },
  2471. );
  2472. });
  2473. }
  2474. },
  2475. });
  2476. Accounts.onCreateUser((options, user) => {
  2477. const userCount = ReactiveCache.getUsers({}, {}, true).count();
  2478. user.isAdmin = userCount === 0;
  2479. if (user.services.oidc) {
  2480. let email = user.services.oidc.email;
  2481. if (Array.isArray(email)) {
  2482. email = email.shift();
  2483. }
  2484. email = email.toLowerCase();
  2485. user.username = user.services.oidc.username;
  2486. user.emails = [
  2487. {
  2488. address: email,
  2489. verified: true,
  2490. },
  2491. ];
  2492. // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176
  2493. // Thanks to mc-marcy and xet7 !
  2494. if (user.username.includes('/') ||
  2495. email.includes('/')) {
  2496. return false;
  2497. }
  2498. const initials = user.services.oidc.fullname
  2499. .split(/\s+/)
  2500. .reduce((memo, word) => {
  2501. return memo + word[0];
  2502. }, '')
  2503. .toUpperCase();
  2504. user.profile = {
  2505. initials,
  2506. fullname: user.services.oidc.fullname,
  2507. boardView: 'board-view-swimlanes',
  2508. };
  2509. user.authenticationMethod = 'oauth2';
  2510. // see if any existing user has this email address or username, otherwise create new
  2511. const existingUser = ReactiveCache.getUser({
  2512. $or: [
  2513. {
  2514. 'emails.address': email,
  2515. },
  2516. {
  2517. username: user.username,
  2518. },
  2519. ],
  2520. });
  2521. if (!existingUser) return user;
  2522. // copy across new service info
  2523. const service = _.keys(user.services)[0];
  2524. existingUser.services[service] = user.services[service];
  2525. existingUser.emails = user.emails;
  2526. existingUser.username = user.username;
  2527. existingUser.profile = user.profile;
  2528. existingUser.authenticationMethod = user.authenticationMethod;
  2529. Meteor.users.remove({
  2530. _id: user._id,
  2531. });
  2532. Meteor.users.remove({
  2533. _id: existingUser._id,
  2534. }); // is going to be created again
  2535. return existingUser;
  2536. }
  2537. if (options.from === 'admin') {
  2538. user.createdThroughApi = true;
  2539. return user;
  2540. }
  2541. const disableRegistration = ReactiveCache.getCurrentSetting().disableRegistration;
  2542. // If this is the first Authentication by the ldap and self registration disabled
  2543. if (disableRegistration && options && options.ldap) {
  2544. user.authenticationMethod = 'ldap';
  2545. return user;
  2546. }
  2547. // If self registration enabled
  2548. if (!disableRegistration) {
  2549. return user;
  2550. }
  2551. if (!options || !options.profile) {
  2552. throw new Meteor.Error(
  2553. 'error-invitation-code-blank',
  2554. 'The invitation code is required',
  2555. );
  2556. }
  2557. const invitationCode = ReactiveCache.getInvitationCode({
  2558. code: options.profile.invitationcode,
  2559. email: options.email,
  2560. valid: true,
  2561. });
  2562. if (!invitationCode) {
  2563. throw new Meteor.Error(
  2564. 'error-invitation-code-not-exist',
  2565. // eslint-disable-next-line quotes
  2566. "The invitation code doesn't exist",
  2567. );
  2568. } else {
  2569. user.profile = {
  2570. icode: options.profile.invitationcode,
  2571. };
  2572. user.profile.boardView = 'board-view-swimlanes';
  2573. // Deletes the invitation code after the user was created successfully.
  2574. setTimeout(
  2575. Meteor.bindEnvironment(() => {
  2576. InvitationCodes.remove({
  2577. _id: invitationCode._id,
  2578. });
  2579. }),
  2580. 200,
  2581. );
  2582. return user;
  2583. }
  2584. });
  2585. }
  2586. const addCronJob = _.debounce(
  2587. Meteor.bindEnvironment(function notificationCleanupDebounced() {
  2588. // passed in the removeAge has to be a number standing for the number of days after a notification is read before we remove it
  2589. const envRemoveAge =
  2590. process.env.NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE;
  2591. // default notifications will be removed 2 days after they are read
  2592. const defaultRemoveAge = 2;
  2593. const removeAge = parseInt(envRemoveAge, 10) || defaultRemoveAge;
  2594. SyncedCron.add({
  2595. name: 'notification_cleanup',
  2596. schedule: (parser) => parser.text('every 1 days'),
  2597. job: () => {
  2598. for (const user of ReactiveCache.getUsers()) {
  2599. if (!user.profile || !user.profile.notifications) continue;
  2600. for (const notification of user.profile.notifications) {
  2601. if (notification.read) {
  2602. const removeDate = new Date(notification.read);
  2603. removeDate.setDate(removeDate.getDate() + removeAge);
  2604. if (removeDate <= new Date()) {
  2605. user.removeNotification(notification.activity);
  2606. }
  2607. }
  2608. }
  2609. }
  2610. },
  2611. });
  2612. SyncedCron.start();
  2613. }),
  2614. 500,
  2615. );
  2616. if (Meteor.isServer) {
  2617. // Let mongoDB ensure username unicity
  2618. Meteor.startup(() => {
  2619. allowedSortValues.forEach((value) => {
  2620. Lists._collection.createIndex(value);
  2621. });
  2622. Users._collection.createIndex({
  2623. modifiedAt: -1,
  2624. });
  2625. // Avatar URLs from CollectionFS to Meteor-Files, at users collection avatarUrl field:
  2626. Users.find({ "profile.avatarUrl": { $regex: "/cfs/files/avatars/" } }).forEach(function (doc) {
  2627. doc.profile.avatarUrl = doc.profile.avatarUrl.replace("/cfs/files/avatars/", "/cdn/storage/avatars/");
  2628. // Try to fix Users.save is not a fuction, by commenting it out:
  2629. //Users.save(doc);
  2630. });
  2631. /* TODO: Optionally, for additional complexity:
  2632. a) Support SubURLs with parthname from ROOT_URL
  2633. b) Remove beginning or avatar URL, replace it with pathname and new avatar URL
  2634. c) Does all avatar and attachment URLs need to be fixed every time when starting or restarting?
  2635. d) What if avatar URL is at some other server? In that case, links would point incorrectly to this instance, if ROOT_URL and path part is removed.
  2636. doc.profile.avatarUrl = process.env.ROOT_URL.pathname + doc.profile.avatarUrl.replace("/cfs/files/avatars/", "/cdn/storage/avatars/").substring(str.indexOf("/cdn/storage/avatars"));
  2637. */
  2638. /* Commented out extra index because of IndexOptionsConflict.
  2639. Users._collection.createIndex(
  2640. {
  2641. username: 1,
  2642. },
  2643. {
  2644. unique: true,
  2645. },
  2646. );
  2647. */
  2648. Meteor.defer(() => {
  2649. addCronJob();
  2650. });
  2651. });
  2652. // OLD WAY THIS CODE DID WORK: When user is last admin of board,
  2653. // if admin is removed, board is removed.
  2654. // NOW THIS IS COMMENTED OUT, because other board users still need to be able
  2655. // to use that board, and not have board deleted.
  2656. // Someone can be later changed to be admin of board, by making change to database.
  2657. // TODO: Add UI for changing someone as board admin.
  2658. //Users.before.remove((userId, doc) => {
  2659. // Boards
  2660. // .find({members: {$elemMatch: {userId: doc._id, isAdmin: true}}})
  2661. // .forEach((board) => {
  2662. // // If only one admin for the board
  2663. // if (board.members.filter((e) => e.isAdmin).length === 1) {
  2664. // Boards.remove(board._id);
  2665. // }
  2666. // });
  2667. //});
  2668. // Each board document contains the de-normalized number of users that have
  2669. // starred it. If the user star or unstar a board, we need to update this
  2670. // counter.
  2671. // We need to run this code on the server only, otherwise the incrementation
  2672. // will be done twice.
  2673. Users.after.update(function (userId, user, fieldNames) {
  2674. // The `starredBoards` list is hosted on the `profile` field. If this
  2675. // field hasn't been modificated we don't need to run this hook.
  2676. if (!_.contains(fieldNames, 'profile')) return;
  2677. // To calculate a diff of board starred ids, we get both the previous
  2678. // and the newly board ids list
  2679. function getStarredBoardsIds(doc) {
  2680. return doc.profile && doc.profile.starredBoards;
  2681. }
  2682. const oldIds = getStarredBoardsIds(this.previous);
  2683. const newIds = getStarredBoardsIds(user);
  2684. // The _.difference(a, b) method returns the values from a that are not in
  2685. // b. We use it to find deleted and newly inserted ids by using it in one
  2686. // direction and then in the other.
  2687. function incrementBoards(boardsIds, inc) {
  2688. boardsIds.forEach((boardId) => {
  2689. Boards.update(boardId, {
  2690. $inc: {
  2691. stars: inc,
  2692. },
  2693. });
  2694. });
  2695. }
  2696. incrementBoards(_.difference(oldIds, newIds), -1);
  2697. incrementBoards(_.difference(newIds, oldIds), +1);
  2698. });
  2699. // Override getUserId so that we can TODO get the current userId
  2700. const fakeUserId = new Meteor.EnvironmentVariable();
  2701. const getUserId = CollectionHooks.getUserId;
  2702. CollectionHooks.getUserId = () => {
  2703. return fakeUserId.get() || getUserId();
  2704. };
  2705. if (!isSandstorm) {
  2706. Users.after.insert((userId, doc) => {
  2707. const fakeUser = {
  2708. extendAutoValueContext: {
  2709. userId: doc._id,
  2710. },
  2711. };
  2712. fakeUserId.withValue(doc._id, () => {
  2713. /*
  2714. // Insert the Welcome Board
  2715. Boards.insert({
  2716. title: TAPi18n.__('welcome-board'),
  2717. permission: 'private',
  2718. }, fakeUser, (err, boardId) => {
  2719. Swimlanes.insert({
  2720. title: TAPi18n.__('welcome-swimlane'),
  2721. boardId,
  2722. sort: 1,
  2723. }, fakeUser);
  2724. ['welcome-list1', 'welcome-list2'].forEach((title, titleIndex) => {
  2725. Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
  2726. });
  2727. });
  2728. */
  2729. // Insert Template Container
  2730. const Future = require('fibers/future');
  2731. const future1 = new Future();
  2732. const future2 = new Future();
  2733. const future3 = new Future();
  2734. Boards.insert(
  2735. {
  2736. title: TAPi18n && TAPi18n.i18n ? TAPi18n.__('templates') : 'Templates',
  2737. permission: 'private',
  2738. type: 'template-container',
  2739. },
  2740. fakeUser,
  2741. (err, boardId) => {
  2742. // Insert the reference to our templates board
  2743. Users.update(fakeUserId.get(), {
  2744. $set: {
  2745. 'profile.templatesBoardId': boardId,
  2746. },
  2747. });
  2748. // Insert the card templates swimlane
  2749. Swimlanes.insert(
  2750. {
  2751. title: TAPi18n && TAPi18n.i18n ? TAPi18n.__('card-templates-swimlane') : 'Card Templates',
  2752. boardId,
  2753. sort: 1,
  2754. type: 'template-container',
  2755. },
  2756. fakeUser,
  2757. (err, swimlaneId) => {
  2758. // Insert the reference to out card templates swimlane
  2759. Users.update(fakeUserId.get(), {
  2760. $set: {
  2761. 'profile.cardTemplatesSwimlaneId': swimlaneId,
  2762. },
  2763. });
  2764. future1.return();
  2765. },
  2766. );
  2767. // Insert the list templates swimlane
  2768. Swimlanes.insert(
  2769. {
  2770. title: TAPi18n && TAPi18n.i18n ? TAPi18n.__('list-templates-swimlane') : 'List Templates',
  2771. boardId,
  2772. sort: 2,
  2773. type: 'template-container',
  2774. },
  2775. fakeUser,
  2776. (err, swimlaneId) => {
  2777. // Insert the reference to out list templates swimlane
  2778. Users.update(fakeUserId.get(), {
  2779. $set: {
  2780. 'profile.listTemplatesSwimlaneId': swimlaneId,
  2781. },
  2782. });
  2783. future2.return();
  2784. },
  2785. );
  2786. // Insert the board templates swimlane
  2787. Swimlanes.insert(
  2788. {
  2789. title: TAPi18n && TAPi18n.i18n ? TAPi18n.__('board-templates-swimlane') : 'Board Templates',
  2790. boardId,
  2791. sort: 3,
  2792. type: 'template-container',
  2793. },
  2794. fakeUser,
  2795. (err, swimlaneId) => {
  2796. // Insert the reference to out board templates swimlane
  2797. Users.update(fakeUserId.get(), {
  2798. $set: {
  2799. 'profile.boardTemplatesSwimlaneId': swimlaneId,
  2800. },
  2801. });
  2802. future3.return();
  2803. },
  2804. );
  2805. },
  2806. );
  2807. // HACK
  2808. future1.wait();
  2809. future2.wait();
  2810. future3.wait();
  2811. // End of Insert Template Container
  2812. });
  2813. });
  2814. }
  2815. Users.after.insert((userId, doc) => {
  2816. // HACK
  2817. doc = ReactiveCache.getUser(doc._id);
  2818. if (doc.createdThroughApi) {
  2819. // The admin user should be able to create a user despite disabling registration because
  2820. // it is two different things (registration and creation).
  2821. // So, when a new user is created via the api (only admin user can do that) one must avoid
  2822. // the disableRegistration check.
  2823. // Issue : https://github.com/wekan/wekan/issues/1232
  2824. // PR : https://github.com/wekan/wekan/pull/1251
  2825. Users.update(doc._id, {
  2826. $set: {
  2827. createdThroughApi: '',
  2828. },
  2829. });
  2830. return;
  2831. }
  2832. //invite user to corresponding boards
  2833. const disableRegistration = ReactiveCache.getCurrentSetting().disableRegistration;
  2834. // If ldap, bypass the inviation code if the self registration isn't allowed.
  2835. // TODO : pay attention if ldap field in the user model change to another content ex : ldap field to connection_type
  2836. if (doc.authenticationMethod !== 'ldap' && disableRegistration) {
  2837. let invitationCode = null;
  2838. if (doc.authenticationMethod.toLowerCase() == 'oauth2') {
  2839. // OIDC authentication mode
  2840. invitationCode = ReactiveCache.getInvitationCode({
  2841. email: doc.emails[0].address.toLowerCase(),
  2842. valid: true,
  2843. });
  2844. } else {
  2845. invitationCode = ReactiveCache.getInvitationCode({
  2846. code: doc.profile.icode,
  2847. valid: true,
  2848. });
  2849. }
  2850. if (!invitationCode) {
  2851. throw new Meteor.Error('error-invitation-code-not-exist');
  2852. } else {
  2853. invitationCode.boardsToBeInvited.forEach((boardId) => {
  2854. const board = ReactiveCache.getBoard(boardId);
  2855. board.addMember(doc._id);
  2856. });
  2857. if (!doc.profile) {
  2858. doc.profile = {};
  2859. }
  2860. doc.profile.invitedBoards = invitationCode.boardsToBeInvited;
  2861. Users.update(doc._id, {
  2862. $set: {
  2863. profile: doc.profile,
  2864. },
  2865. });
  2866. InvitationCodes.update(invitationCode._id, {
  2867. $set: {
  2868. valid: false,
  2869. },
  2870. });
  2871. }
  2872. }
  2873. });
  2874. }
  2875. // USERS REST API
  2876. if (Meteor.isServer) {
  2877. // Middleware which checks that API is enabled.
  2878. JsonRoutes.Middleware.use(function (req, res, next) {
  2879. const api = req.url.startsWith('/api');
  2880. if ((api === true && process.env.WITH_API === 'true') || api === false) {
  2881. return next();
  2882. } else {
  2883. res.writeHead(301, {
  2884. Location: '/',
  2885. });
  2886. return res.end();
  2887. }
  2888. });
  2889. /**
  2890. * @operation get_current_user
  2891. *
  2892. * @summary returns the current user
  2893. * @return_type Users
  2894. */
  2895. JsonRoutes.add('GET', '/api/user', function (req, res) {
  2896. try {
  2897. Authentication.checkLoggedIn(req.userId);
  2898. const data = ReactiveCache.getUser({
  2899. _id: req.userId,
  2900. });
  2901. delete data.services;
  2902. // get all boards where the user is member of
  2903. let boards = ReactiveCache.getBoards(
  2904. {
  2905. type: 'board',
  2906. 'members.userId': req.userId,
  2907. },
  2908. {
  2909. fields: {
  2910. _id: 1,
  2911. members: 1,
  2912. },
  2913. },
  2914. );
  2915. boards = boards.map((b) => {
  2916. const u = b.members.find((m) => m.userId === req.userId);
  2917. delete u.userId;
  2918. u.boardId = b._id;
  2919. return u;
  2920. });
  2921. data.boards = boards;
  2922. JsonRoutes.sendResult(res, {
  2923. code: 200,
  2924. data,
  2925. });
  2926. } catch (error) {
  2927. JsonRoutes.sendResult(res, {
  2928. code: 200,
  2929. data: error,
  2930. });
  2931. }
  2932. });
  2933. /**
  2934. * @operation get_all_users
  2935. *
  2936. * @summary return all the users
  2937. *
  2938. * @description Only the admin user (the first user) can call the REST API.
  2939. * @return_type [{ _id: string,
  2940. * username: string}]
  2941. */
  2942. JsonRoutes.add('GET', '/api/users', function (req, res) {
  2943. try {
  2944. Authentication.checkUserId(req.userId);
  2945. JsonRoutes.sendResult(res, {
  2946. code: 200,
  2947. data: Meteor.users.find({}).map(function (doc) {
  2948. return {
  2949. _id: doc._id,
  2950. username: doc.username,
  2951. };
  2952. }),
  2953. });
  2954. } catch (error) {
  2955. JsonRoutes.sendResult(res, {
  2956. code: 200,
  2957. data: error,
  2958. });
  2959. }
  2960. });
  2961. /**
  2962. * @operation get_user
  2963. *
  2964. * @summary get a given user
  2965. *
  2966. * @description Only the admin user (the first user) can call the REST API.
  2967. *
  2968. * @param {string} userId the user ID or username
  2969. * @return_type Users
  2970. */
  2971. JsonRoutes.add('GET', '/api/users/:userId', function (req, res) {
  2972. try {
  2973. Authentication.checkUserId(req.userId);
  2974. let id = req.params.userId;
  2975. let user = ReactiveCache.getUser({
  2976. _id: id,
  2977. });
  2978. if (!user) {
  2979. user = ReactiveCache.getUser({
  2980. username: id,
  2981. });
  2982. id = user._id;
  2983. }
  2984. // get all boards where the user is member of
  2985. let boards = ReactiveCache.getBoards(
  2986. {
  2987. type: 'board',
  2988. 'members.userId': id,
  2989. },
  2990. {
  2991. fields: {
  2992. _id: 1,
  2993. members: 1,
  2994. },
  2995. },
  2996. );
  2997. boards = boards.map((b) => {
  2998. const u = b.members.find((m) => m.userId === id);
  2999. delete u.userId;
  3000. u.boardId = b._id;
  3001. return u;
  3002. });
  3003. user.boards = boards;
  3004. JsonRoutes.sendResult(res, {
  3005. code: 200,
  3006. data: user,
  3007. });
  3008. } catch (error) {
  3009. JsonRoutes.sendResult(res, {
  3010. code: 200,
  3011. data: error,
  3012. });
  3013. }
  3014. });
  3015. /**
  3016. * @operation edit_user
  3017. *
  3018. * @summary edit a given user
  3019. *
  3020. * @description Only the admin user (the first user) can call the REST API.
  3021. *
  3022. * Possible values for *action*:
  3023. * - `takeOwnership`: The admin takes the ownership of ALL boards of the user (archived and not archived) where the user is admin on.
  3024. * - `disableLogin`: Disable a user (the user is not allowed to login and his login tokens are purged)
  3025. * - `enableLogin`: Enable a user
  3026. *
  3027. * @param {string} userId the user ID
  3028. * @param {string} action the action
  3029. * @return_type {_id: string,
  3030. * title: string}
  3031. */
  3032. JsonRoutes.add('PUT', '/api/users/:userId', function (req, res) {
  3033. try {
  3034. Authentication.checkUserId(req.userId);
  3035. const id = req.params.userId;
  3036. const action = req.body.action;
  3037. let data = ReactiveCache.getUser({
  3038. _id: id,
  3039. });
  3040. if (data !== undefined) {
  3041. if (action === 'takeOwnership') {
  3042. data = ReactiveCache.getBoards(
  3043. {
  3044. 'members.userId': id,
  3045. 'members.isAdmin': true,
  3046. },
  3047. {
  3048. sort: {
  3049. sort: 1 /* boards default sorting */,
  3050. },
  3051. },
  3052. ).map(function (board) {
  3053. if (board.hasMember(req.userId)) {
  3054. board.removeMember(req.userId);
  3055. }
  3056. board.changeOwnership(id, req.userId);
  3057. return {
  3058. _id: board._id,
  3059. title: board.title,
  3060. };
  3061. });
  3062. } else {
  3063. if (action === 'disableLogin' && id !== req.userId) {
  3064. Users.update(
  3065. {
  3066. _id: id,
  3067. },
  3068. {
  3069. $set: {
  3070. loginDisabled: true,
  3071. 'services.resume.loginTokens': '',
  3072. },
  3073. },
  3074. );
  3075. } else if (action === 'enableLogin') {
  3076. Users.update(
  3077. {
  3078. _id: id,
  3079. },
  3080. {
  3081. $set: {
  3082. loginDisabled: '',
  3083. },
  3084. },
  3085. );
  3086. }
  3087. data = ReactiveCache.getUser(id);
  3088. }
  3089. }
  3090. JsonRoutes.sendResult(res, {
  3091. code: 200,
  3092. data,
  3093. });
  3094. } catch (error) {
  3095. JsonRoutes.sendResult(res, {
  3096. code: 200,
  3097. data: error,
  3098. });
  3099. }
  3100. });
  3101. /**
  3102. * @operation add_board_member
  3103. * @tag Boards
  3104. *
  3105. * @summary Add New Board Member with Role
  3106. *
  3107. * @description Only the admin user (the first user) can call the REST API.
  3108. *
  3109. * **Note**: see [Boards.set_board_member_permission](#set_board_member_permission)
  3110. * to later change the permissions.
  3111. *
  3112. * @param {string} boardId the board ID
  3113. * @param {string} userId the user ID
  3114. * @param {string} action the action (needs to be `add`)
  3115. * @param {boolean} isAdmin is the user an admin of the board
  3116. * @param {boolean} isNoComments disable comments
  3117. * @param {boolean} isCommentOnly only enable comments
  3118. * @param {boolean} isWorker is the user a board worker
  3119. * @param {boolean} isNormalAssignedOnly only see assigned cards (Normal permission)
  3120. * @param {boolean} isCommentAssignedOnly only comment on assigned cards
  3121. * @param {boolean} isReadOnly read-only access (no comments or editing)
  3122. * @param {boolean} isReadAssignedOnly read-only assigned cards only
  3123. * @return_type {_id: string,
  3124. * title: string}
  3125. */
  3126. JsonRoutes.add(
  3127. 'POST',
  3128. '/api/boards/:boardId/members/:userId/add',
  3129. function (req, res) {
  3130. try {
  3131. Authentication.checkUserId(req.userId);
  3132. const userId = req.params.userId;
  3133. const boardId = req.params.boardId;
  3134. const action = req.body.action;
  3135. const { isAdmin, isNoComments, isCommentOnly, isWorker, isNormalAssignedOnly, isCommentAssignedOnly, isReadOnly, isReadAssignedOnly } = req.body;
  3136. let data = ReactiveCache.getUser(userId);
  3137. if (data !== undefined) {
  3138. if (action === 'add') {
  3139. data = ReactiveCache.getBoards({
  3140. _id: boardId,
  3141. }).map(function (board) {
  3142. if (!board.hasMember(userId)) {
  3143. board.addMember(userId);
  3144. function isTrue(data) {
  3145. return data.toLowerCase() === 'true';
  3146. }
  3147. board.setMemberPermission(
  3148. userId,
  3149. isTrue(isAdmin),
  3150. isTrue(isNoComments),
  3151. isTrue(isCommentOnly),
  3152. isTrue(isWorker),
  3153. isTrue(isNormalAssignedOnly),
  3154. isTrue(isCommentAssignedOnly),
  3155. isTrue(isReadOnly),
  3156. isTrue(isReadAssignedOnly),
  3157. userId,
  3158. );
  3159. }
  3160. return {
  3161. _id: board._id,
  3162. title: board.title,
  3163. };
  3164. });
  3165. }
  3166. }
  3167. JsonRoutes.sendResult(res, { code: 200, data });
  3168. } catch (error) {
  3169. JsonRoutes.sendResult(res, {
  3170. code: 200,
  3171. data: error,
  3172. });
  3173. }
  3174. },
  3175. );
  3176. /**
  3177. * @operation remove_board_member
  3178. * @tag Boards
  3179. *
  3180. * @summary Remove Member from Board
  3181. *
  3182. * @description Only the admin user (the first user) can call the REST API.
  3183. *
  3184. * @param {string} boardId the board ID
  3185. * @param {string} userId the user ID
  3186. * @param {string} action the action (needs to be `remove`)
  3187. * @return_type {_id: string,
  3188. * title: string}
  3189. */
  3190. JsonRoutes.add(
  3191. 'POST',
  3192. '/api/boards/:boardId/members/:userId/remove',
  3193. function (req, res) {
  3194. try {
  3195. Authentication.checkUserId(req.userId);
  3196. const userId = req.params.userId;
  3197. const boardId = req.params.boardId;
  3198. const action = req.body.action;
  3199. let data = ReactiveCache.getUser(userId);
  3200. if (data !== undefined) {
  3201. if (action === 'remove') {
  3202. data = ReactiveCache.getBoards({
  3203. _id: boardId,
  3204. }).map(function (board) {
  3205. if (board.hasMember(userId)) {
  3206. board.removeMember(userId);
  3207. }
  3208. return {
  3209. _id: board._id,
  3210. title: board.title,
  3211. };
  3212. });
  3213. }
  3214. }
  3215. JsonRoutes.sendResult(res, { code: 200, data });
  3216. } catch (error) {
  3217. JsonRoutes.sendResult(res, {
  3218. code: 200,
  3219. data: error,
  3220. });
  3221. }
  3222. },
  3223. );
  3224. /**
  3225. * @operation new_user
  3226. *
  3227. * @summary Create a new user
  3228. *
  3229. * @description Only the admin user (the first user) can call the REST API.
  3230. *
  3231. * @param {string} username the new username
  3232. * @param {string} email the email of the new user
  3233. * @param {string} password the password of the new user
  3234. * @return_type {_id: string}
  3235. */
  3236. JsonRoutes.add('POST', '/api/users/', function (req, res) {
  3237. try {
  3238. Authentication.checkUserId(req.userId);
  3239. const id = Accounts.createUser({
  3240. username: req.body.username,
  3241. email: req.body.email,
  3242. password: req.body.password,
  3243. from: 'admin',
  3244. });
  3245. JsonRoutes.sendResult(res, {
  3246. code: 200,
  3247. data: {
  3248. _id: id,
  3249. },
  3250. });
  3251. } catch (error) {
  3252. JsonRoutes.sendResult(res, {
  3253. code: 200,
  3254. data: error,
  3255. });
  3256. }
  3257. });
  3258. /**
  3259. * @operation delete_user
  3260. *
  3261. * @summary Delete a user
  3262. *
  3263. * @description Only the admin user (the first user) can call the REST API.
  3264. *
  3265. * @param {string} userId the ID of the user to delete
  3266. * @return_type {_id: string}
  3267. */
  3268. JsonRoutes.add('DELETE', '/api/users/:userId', function (req, res) {
  3269. try {
  3270. Authentication.checkUserId(req.userId);
  3271. const id = req.params.userId;
  3272. // Delete user is enabled, but is still has bug of leaving empty user avatars
  3273. // to boards: boards members, card members and assignees have
  3274. // empty users. So it would be better to delete user from all boards before
  3275. // deleting user.
  3276. // See:
  3277. // - wekan/client/components/settings/peopleBody.jade deleteButton
  3278. // - wekan/client/components/settings/peopleBody.js deleteButton
  3279. // - wekan/client/components/sidebar/sidebar.js Popup.afterConfirm('removeMember'
  3280. // that does now remove member from board, card members and assignees correctly,
  3281. // but that should be used to remove user from all boards similarly
  3282. // - wekan/models/users.js Delete is not enabled
  3283. Meteor.users.remove({ _id: id });
  3284. JsonRoutes.sendResult(res, {
  3285. code: 200,
  3286. data: {
  3287. _id: id,
  3288. },
  3289. });
  3290. } catch (error) {
  3291. JsonRoutes.sendResult(res, {
  3292. code: 200,
  3293. data: error,
  3294. });
  3295. }
  3296. });
  3297. /**
  3298. * @operation create_user_token
  3299. *
  3300. * @summary Create a user token
  3301. *
  3302. * @description Only the admin user (the first user) can call the REST API.
  3303. *
  3304. * @param {string} userId the ID of the user to create token for.
  3305. * @return_type {_id: string}
  3306. */
  3307. JsonRoutes.add('POST', '/api/createtoken/:userId', function (req, res) {
  3308. try {
  3309. Authentication.checkUserId(req.userId);
  3310. const id = req.params.userId;
  3311. const token = Accounts._generateStampedLoginToken();
  3312. Accounts._insertLoginToken(id, token);
  3313. JsonRoutes.sendResult(res, {
  3314. code: 200,
  3315. data: {
  3316. _id: id,
  3317. authToken: token.token,
  3318. },
  3319. });
  3320. } catch (error) {
  3321. JsonRoutes.sendResult(res, {
  3322. code: 200,
  3323. data: error,
  3324. });
  3325. }
  3326. });
  3327. /**
  3328. * @operation delete_user_token
  3329. *
  3330. * @summary Delete one or all user token.
  3331. *
  3332. * @description Only the admin user (the first user) can call the REST API.
  3333. *
  3334. * @param {string} userId the user ID
  3335. * @param {string} token the user hashedToken
  3336. * @return_type {message: string}
  3337. */
  3338. JsonRoutes.add('POST', '/api/deletetoken', function (req, res) {
  3339. try {
  3340. const { userId, token } = req.body;
  3341. Authentication.checkUserId(req.userId);
  3342. let data = {
  3343. message: 'Expected a userId to be set but received none.',
  3344. };
  3345. if (token && userId) {
  3346. Accounts.destroyToken(userId, token);
  3347. data.message = 'Delete token: [' + token + '] from user: ' + userId;
  3348. } else if (userId) {
  3349. check(userId, String);
  3350. Users.update(
  3351. {
  3352. _id: userId,
  3353. },
  3354. {
  3355. $set: {
  3356. 'services.resume.loginTokens': '',
  3357. },
  3358. },
  3359. );
  3360. data.message = 'Delete all token from user: ' + userId;
  3361. }
  3362. JsonRoutes.sendResult(res, {
  3363. code: 200,
  3364. data,
  3365. });
  3366. } catch (error) {
  3367. JsonRoutes.sendResult(res, {
  3368. code: 200,
  3369. data: error,
  3370. });
  3371. }
  3372. });
  3373. // Server-side method to sanitize user data for search results
  3374. Meteor.methods({
  3375. sanitizeUserForSearch(userData) {
  3376. check(userData, Object);
  3377. // Only allow safe fields for user search
  3378. const safeFields = {
  3379. _id: 1,
  3380. username: 1,
  3381. 'profile.fullname': 1,
  3382. 'profile.avatarUrl': 1,
  3383. 'profile.initials': 1,
  3384. 'emails.address': 1,
  3385. 'emails.verified': 1,
  3386. authenticationMethod: 1,
  3387. isAdmin: 1,
  3388. loginDisabled: 1,
  3389. teams: 1,
  3390. orgs: 1,
  3391. };
  3392. const sanitized = {};
  3393. for (const field of Object.keys(safeFields)) {
  3394. if (userData[field] !== undefined) {
  3395. sanitized[field] = userData[field];
  3396. }
  3397. }
  3398. // Ensure sensitive fields are never included
  3399. delete sanitized.services;
  3400. delete sanitized.resume;
  3401. delete sanitized.email;
  3402. delete sanitized.createdAt;
  3403. delete sanitized.modifiedAt;
  3404. delete sanitized.sessionData;
  3405. delete sanitized.importUsernames;
  3406. if (process.env.DEBUG === 'true') {
  3407. console.log('Sanitized user data for search:', Object.keys(sanitized));
  3408. }
  3409. return sanitized;
  3410. }
  3411. });
  3412. }
  3413. export default Users;