diff options
Diffstat (limited to 'src/stores/UserStore.ts')
-rw-r--r-- | src/stores/UserStore.ts | 421 |
1 files changed, 421 insertions, 0 deletions
diff --git a/src/stores/UserStore.ts b/src/stores/UserStore.ts new file mode 100644 index 000000000..616ff29a6 --- /dev/null +++ b/src/stores/UserStore.ts | |||
@@ -0,0 +1,421 @@ | |||
1 | import { observable, computed, action } from 'mobx'; | ||
2 | import moment from 'moment'; | ||
3 | import jwt from 'jsonwebtoken'; | ||
4 | import localStorage from 'mobx-localstorage'; | ||
5 | import { ipcRenderer } from 'electron'; | ||
6 | |||
7 | import { ApiInterface } from 'src/api'; | ||
8 | import { Actions } from 'src/actions/lib/actions'; | ||
9 | import { Stores } from 'src/stores.types'; | ||
10 | import { TODOS_PARTITION_ID } from '../config'; | ||
11 | import { isDevMode } from '../environment-remote'; | ||
12 | import Request from './lib/Request'; | ||
13 | import CachedRequest from './lib/CachedRequest'; | ||
14 | import TypedStore from './lib/TypedStore'; | ||
15 | |||
16 | const debug = require('../preload-safe-debug')('Ferdium:UserStore'); | ||
17 | |||
18 | // TODO: split stores into UserStore and AuthStore | ||
19 | export default class UserStore extends TypedStore { | ||
20 | BASE_ROUTE: string = '/auth'; | ||
21 | |||
22 | WELCOME_ROUTE: string = `${this.BASE_ROUTE}/welcome`; | ||
23 | |||
24 | LOGIN_ROUTE: string = `${this.BASE_ROUTE}/login`; | ||
25 | |||
26 | LOGOUT_ROUTE: string = `${this.BASE_ROUTE}/logout`; | ||
27 | |||
28 | SIGNUP_ROUTE: string = `${this.BASE_ROUTE}/signup`; | ||
29 | |||
30 | SETUP_ROUTE: string = `${this.BASE_ROUTE}/signup/setup`; | ||
31 | |||
32 | IMPORT_ROUTE: string = `${this.BASE_ROUTE}/signup/import`; | ||
33 | |||
34 | INVITE_ROUTE: string = `${this.BASE_ROUTE}/signup/invite`; | ||
35 | |||
36 | PASSWORD_ROUTE: string = `${this.BASE_ROUTE}/password`; | ||
37 | |||
38 | CHANGE_SERVER_ROUTE: string = `${this.BASE_ROUTE}/server`; | ||
39 | |||
40 | @observable loginRequest: Request = new Request(this.api.user, 'login'); | ||
41 | |||
42 | @observable signupRequest: Request = new Request(this.api.user, 'signup'); | ||
43 | |||
44 | @observable passwordRequest: Request = new Request(this.api.user, 'password'); | ||
45 | |||
46 | @observable inviteRequest: Request = new Request(this.api.user, 'invite'); | ||
47 | |||
48 | @observable getUserInfoRequest: CachedRequest = new CachedRequest( | ||
49 | this.api.user, | ||
50 | 'getInfo', | ||
51 | ); | ||
52 | |||
53 | @observable updateUserInfoRequest: Request = new Request( | ||
54 | this.api.user, | ||
55 | 'updateInfo', | ||
56 | ); | ||
57 | |||
58 | @observable getLegacyServicesRequest: CachedRequest = new CachedRequest( | ||
59 | this.api.user, | ||
60 | 'getLegacyServices', | ||
61 | ); | ||
62 | |||
63 | @observable deleteAccountRequest: CachedRequest = new CachedRequest( | ||
64 | this.api.user, | ||
65 | 'delete', | ||
66 | ); | ||
67 | |||
68 | @observable isImportLegacyServicesExecuting: boolean = false; | ||
69 | |||
70 | @observable isImportLegacyServicesCompleted: boolean = false; | ||
71 | |||
72 | @observable isLoggingOut: boolean = false; | ||
73 | |||
74 | @observable id: string | null | undefined; | ||
75 | |||
76 | @observable authToken: string | null = | ||
77 | localStorage.getItem('authToken') || null; | ||
78 | |||
79 | @observable accountType: string | undefined; | ||
80 | |||
81 | @observable hasCompletedSignup: boolean = false; | ||
82 | |||
83 | @observable userData: object = {}; | ||
84 | |||
85 | logoutReasonTypes = { | ||
86 | SERVER: 'SERVER', | ||
87 | }; | ||
88 | |||
89 | @observable logoutReason: string | null = null; | ||
90 | |||
91 | fetchUserInfoInterval = null; | ||
92 | |||
93 | constructor(stores: Stores, api: ApiInterface, actions: Actions) { | ||
94 | super(stores, api, actions); | ||
95 | |||
96 | // Register action handlers | ||
97 | this.actions.user.login.listen(this._login.bind(this)); | ||
98 | this.actions.user.retrievePassword.listen( | ||
99 | this._retrievePassword.bind(this), | ||
100 | ); | ||
101 | this.actions.user.logout.listen(this._logout.bind(this)); | ||
102 | this.actions.user.signup.listen(this._signup.bind(this)); | ||
103 | this.actions.user.invite.listen(this._invite.bind(this)); | ||
104 | this.actions.user.update.listen(this._update.bind(this)); | ||
105 | this.actions.user.resetStatus.listen(this._resetStatus.bind(this)); | ||
106 | this.actions.user.importLegacyServices.listen( | ||
107 | this._importLegacyServices.bind(this), | ||
108 | ); | ||
109 | this.actions.user.delete.listen(this._delete.bind(this)); | ||
110 | |||
111 | // Reactions | ||
112 | this.registerReactions([ | ||
113 | this._requireAuthenticatedUser.bind(this), | ||
114 | this._getUserData.bind(this), | ||
115 | ]); | ||
116 | } | ||
117 | |||
118 | setup(): void { | ||
119 | // Data migration | ||
120 | this._migrateUserLocale(); | ||
121 | } | ||
122 | |||
123 | // Routes | ||
124 | get loginRoute(): string { | ||
125 | return this.LOGIN_ROUTE; | ||
126 | } | ||
127 | |||
128 | get logoutRoute(): string { | ||
129 | return this.LOGOUT_ROUTE; | ||
130 | } | ||
131 | |||
132 | get signupRoute(): string { | ||
133 | return this.SIGNUP_ROUTE; | ||
134 | } | ||
135 | |||
136 | get setupRoute(): string { | ||
137 | return this.SETUP_ROUTE; | ||
138 | } | ||
139 | |||
140 | get inviteRoute(): string { | ||
141 | return this.INVITE_ROUTE; | ||
142 | } | ||
143 | |||
144 | get importRoute(): string { | ||
145 | return this.IMPORT_ROUTE; | ||
146 | } | ||
147 | |||
148 | get passwordRoute(): string { | ||
149 | return this.PASSWORD_ROUTE; | ||
150 | } | ||
151 | |||
152 | get changeServerRoute(): string { | ||
153 | return this.CHANGE_SERVER_ROUTE; | ||
154 | } | ||
155 | |||
156 | // Data | ||
157 | @computed get isLoggedIn(): boolean { | ||
158 | return Boolean(localStorage.getItem('authToken')); | ||
159 | } | ||
160 | |||
161 | @computed get isTokenExpired(): boolean { | ||
162 | if (!this.authToken) return false; | ||
163 | const parsedToken = this._parseToken(this.authToken); | ||
164 | |||
165 | return ( | ||
166 | parsedToken !== false && | ||
167 | this.authToken !== null && | ||
168 | moment(parsedToken.tokenExpiry).isBefore(moment()) | ||
169 | ); | ||
170 | } | ||
171 | |||
172 | @computed get data() { | ||
173 | if (!this.isLoggedIn) return {}; | ||
174 | |||
175 | return this.getUserInfoRequest.execute().result || {}; | ||
176 | } | ||
177 | |||
178 | @computed get team(): any { | ||
179 | return this.data.team || null; | ||
180 | } | ||
181 | |||
182 | @computed get legacyServices(): any { | ||
183 | return this.getLegacyServicesRequest.execute() || {}; | ||
184 | } | ||
185 | |||
186 | // Actions | ||
187 | @action async _login({ email, password }): Promise<void> { | ||
188 | const authToken = await this.loginRequest.execute(email, password)._promise; | ||
189 | this._setUserData(authToken); | ||
190 | |||
191 | this.stores.router.push('/'); | ||
192 | } | ||
193 | |||
194 | @action _tokenLogin(authToken: string): void { | ||
195 | this._setUserData(authToken); | ||
196 | |||
197 | this.stores.router.push('/'); | ||
198 | } | ||
199 | |||
200 | @action async _signup({ | ||
201 | firstname, | ||
202 | lastname, | ||
203 | email, | ||
204 | password, | ||
205 | accountType, | ||
206 | company, | ||
207 | plan, | ||
208 | currency, | ||
209 | }): Promise<void> { | ||
210 | const authToken = await this.signupRequest.execute({ | ||
211 | firstname, | ||
212 | lastname, | ||
213 | email, | ||
214 | password, | ||
215 | accountType, | ||
216 | company, | ||
217 | locale: this.stores.app.locale, | ||
218 | plan, | ||
219 | currency, | ||
220 | }); | ||
221 | |||
222 | this.hasCompletedSignup = true; | ||
223 | |||
224 | this._setUserData(authToken); | ||
225 | |||
226 | this.stores.router.push(this.SETUP_ROUTE); | ||
227 | } | ||
228 | |||
229 | @action async _retrievePassword({ email }): Promise<void> { | ||
230 | const request = this.passwordRequest.execute(email); | ||
231 | |||
232 | await request._promise; | ||
233 | this.actionStatus = request.result.status || []; | ||
234 | } | ||
235 | |||
236 | @action async _invite({ invites }): Promise<void> { | ||
237 | const data = invites.filter(invite => invite.email !== ''); | ||
238 | |||
239 | const response = await this.inviteRequest.execute(data)._promise; | ||
240 | |||
241 | this.actionStatus = response.status || []; | ||
242 | |||
243 | // we do not wait for a server response before redirecting the user ONLY DURING SIGNUP | ||
244 | if (this.stores.router.location.pathname.includes(this.INVITE_ROUTE)) { | ||
245 | this.stores.router.push('/'); | ||
246 | } | ||
247 | } | ||
248 | |||
249 | @action async _update({ userData }): Promise<void> { | ||
250 | if (!this.isLoggedIn) return; | ||
251 | |||
252 | const response = await this.updateUserInfoRequest.execute(userData) | ||
253 | ._promise; | ||
254 | |||
255 | this.getUserInfoRequest.patch(() => response.data); | ||
256 | this.actionStatus = response.status || []; | ||
257 | } | ||
258 | |||
259 | @action _resetStatus(): void { | ||
260 | this.actionStatus = []; | ||
261 | } | ||
262 | |||
263 | @action _logout(): void { | ||
264 | // workaround mobx issue | ||
265 | localStorage.removeItem('authToken'); | ||
266 | window.localStorage.removeItem('authToken'); | ||
267 | |||
268 | this.getUserInfoRequest.invalidate().reset(); | ||
269 | this.authToken = null; | ||
270 | |||
271 | this.stores.services.allServicesRequest.invalidate().reset(); | ||
272 | |||
273 | if (this.stores.todos.isTodosEnabled) { | ||
274 | ipcRenderer.send('clear-storage-data', { sessionId: TODOS_PARTITION_ID }); | ||
275 | } | ||
276 | } | ||
277 | |||
278 | @action async _importLegacyServices({ services }): Promise<void> { | ||
279 | this.isImportLegacyServicesExecuting = true; | ||
280 | |||
281 | // Reduces recipe duplicates | ||
282 | const recipes = services | ||
283 | .filter( | ||
284 | (obj, pos, arr) => | ||
285 | arr.map(mapObj => mapObj.recipe.id).indexOf(obj.recipe.id) === pos, | ||
286 | ) | ||
287 | .map(s => s.recipe.id); | ||
288 | |||
289 | // Install recipes | ||
290 | for (const recipe of recipes) { | ||
291 | // eslint-disable-next-line no-await-in-loop | ||
292 | await this.stores.recipes._install({ recipeId: recipe }); | ||
293 | } | ||
294 | |||
295 | for (const service of services) { | ||
296 | this.actions.service.createFromLegacyService({ | ||
297 | data: service, | ||
298 | }); | ||
299 | // eslint-disable-next-line no-await-in-loop | ||
300 | await this.stores.services.createServiceRequest._promise; | ||
301 | } | ||
302 | |||
303 | this.isImportLegacyServicesExecuting = false; | ||
304 | this.isImportLegacyServicesCompleted = true; | ||
305 | } | ||
306 | |||
307 | @action async _delete(): Promise<void> { | ||
308 | this.deleteAccountRequest.execute(); | ||
309 | } | ||
310 | |||
311 | // This is a mobx autorun which forces the user to login if not authenticated | ||
312 | _requireAuthenticatedUser = (): void => { | ||
313 | if (this.isTokenExpired) { | ||
314 | this._logout(); | ||
315 | } | ||
316 | |||
317 | const { router } = this.stores; | ||
318 | const currentRoute = window.location.hash; | ||
319 | if (!this.isLoggedIn && currentRoute.includes('token=')) { | ||
320 | router.push(this.WELCOME_ROUTE); | ||
321 | const token = currentRoute.split('=')[1]; | ||
322 | |||
323 | const data = this._parseToken(token); | ||
324 | if (data) { | ||
325 | // Give this some time to sink | ||
326 | setTimeout(() => { | ||
327 | this._tokenLogin(token); | ||
328 | }, 1000); | ||
329 | } | ||
330 | } else if (!this.isLoggedIn && !currentRoute.includes(this.BASE_ROUTE)) { | ||
331 | router.push(this.WELCOME_ROUTE); | ||
332 | } else if (this.isLoggedIn && currentRoute === this.LOGOUT_ROUTE) { | ||
333 | this.actions.user.logout(); | ||
334 | router.push(this.LOGIN_ROUTE); | ||
335 | } else if ( | ||
336 | this.isLoggedIn && | ||
337 | currentRoute.includes(this.BASE_ROUTE) && | ||
338 | (this.hasCompletedSignup || this.hasCompletedSignup === null) && | ||
339 | !isDevMode | ||
340 | ) { | ||
341 | this.stores.router.push('/'); | ||
342 | } | ||
343 | }; | ||
344 | |||
345 | // Reactions | ||
346 | async _getUserData(): Promise<void> { | ||
347 | if (this.isLoggedIn) { | ||
348 | let data; | ||
349 | try { | ||
350 | data = await this.getUserInfoRequest.execute()._promise; | ||
351 | } catch { | ||
352 | return; | ||
353 | } | ||
354 | |||
355 | // We need to set the beta flag for the SettingsStore | ||
356 | this.actions.settings.update({ | ||
357 | type: 'app', | ||
358 | data: { | ||
359 | beta: data.beta, | ||
360 | locale: data.locale, | ||
361 | }, | ||
362 | }); | ||
363 | } | ||
364 | } | ||
365 | |||
366 | // Helpers | ||
367 | _parseToken(authToken) { | ||
368 | try { | ||
369 | const decoded = jwt.decode(authToken); | ||
370 | |||
371 | return { | ||
372 | id: decoded.userId, | ||
373 | tokenExpiry: moment.unix(decoded.exp).toISOString(), | ||
374 | authToken, | ||
375 | }; | ||
376 | } catch { | ||
377 | this._logout(); | ||
378 | return false; | ||
379 | } | ||
380 | } | ||
381 | |||
382 | _setUserData(authToken: any): void { | ||
383 | const data = this._parseToken(authToken); | ||
384 | if (data !== false && data.authToken) { | ||
385 | localStorage.setItem('authToken', data.authToken); | ||
386 | |||
387 | this.authToken = data.authToken; | ||
388 | this.id = data.id; | ||
389 | } else { | ||
390 | this.authToken = null; | ||
391 | this.id = null; | ||
392 | } | ||
393 | } | ||
394 | |||
395 | getAuthURL(url: string): string { | ||
396 | const parsedUrl = new URL(url); | ||
397 | const params = new URLSearchParams(parsedUrl.search.slice(1)); | ||
398 | |||
399 | // TODO: Remove the neccesity for `as string` | ||
400 | params.append('authToken', this.authToken as string); | ||
401 | |||
402 | return `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`; | ||
403 | } | ||
404 | |||
405 | async _migrateUserLocale(): Promise<void> { | ||
406 | try { | ||
407 | await this.getUserInfoRequest._promise; | ||
408 | } catch { | ||
409 | return; | ||
410 | } | ||
411 | |||
412 | if (!this.data.locale) { | ||
413 | debug('Migrate "locale" to user data'); | ||
414 | this.actions.user.update({ | ||
415 | userData: { | ||
416 | locale: this.stores.app.locale, | ||
417 | }, | ||
418 | }); | ||
419 | } | ||
420 | } | ||
421 | } | ||