@peassoft/mnr-web-topline 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/css/index.css +16 -0
  2. package/dist/en/modules/password-change-beacon/index.d.ts +14 -0
  3. package/dist/en/modules/password-change-beacon/index.js +25 -0
  4. package/dist/en/modules/request/index.js +1 -2
  5. package/dist/en/modules/topline-service/inner-service.js +3 -3
  6. package/dist/en/modules/websocket/index.js +9 -3
  7. package/dist/en/parts/profile/actions/change-password/index.d.ts +16 -0
  8. package/dist/en/parts/profile/actions/change-password/index.js +102 -0
  9. package/dist/en/parts/profile/ui/index.js +4 -2
  10. package/dist/en/parts/profile/ui/password-change-confirmation/index.d.ts +6 -0
  11. package/dist/en/parts/profile/ui/password-change-confirmation/index.js +22 -0
  12. package/dist/en/parts/profile/ui/password-change-form/index.d.ts +7 -0
  13. package/dist/en/parts/profile/ui/password-change-form/index.js +136 -0
  14. package/dist/en/parts/profile/ui/password-change-form/use-state-with-validation-reset.d.ts +2 -0
  15. package/dist/en/parts/profile/ui/password-change-form/use-state-with-validation-reset.js +9 -0
  16. package/dist/en/parts/profile/ui/password-change-marshal/index.d.ts +6 -0
  17. package/dist/en/parts/profile/ui/password-change-marshal/index.js +32 -0
  18. package/dist/ru/modules/password-change-beacon/index.d.ts +14 -0
  19. package/dist/ru/modules/password-change-beacon/index.js +25 -0
  20. package/dist/ru/modules/request/index.js +1 -2
  21. package/dist/ru/modules/topline-service/inner-service.js +3 -3
  22. package/dist/ru/modules/websocket/index.js +9 -3
  23. package/dist/ru/parts/profile/actions/change-password/index.d.ts +16 -0
  24. package/dist/ru/parts/profile/actions/change-password/index.js +102 -0
  25. package/dist/ru/parts/profile/ui/index.js +4 -2
  26. package/dist/ru/parts/profile/ui/password-change-confirmation/index.d.ts +6 -0
  27. package/dist/ru/parts/profile/ui/password-change-confirmation/index.js +22 -0
  28. package/dist/ru/parts/profile/ui/password-change-form/index.d.ts +7 -0
  29. package/dist/ru/parts/profile/ui/password-change-form/index.js +136 -0
  30. package/dist/ru/parts/profile/ui/password-change-form/use-state-with-validation-reset.d.ts +2 -0
  31. package/dist/ru/parts/profile/ui/password-change-form/use-state-with-validation-reset.js +9 -0
  32. package/dist/ru/parts/profile/ui/password-change-marshal/index.d.ts +6 -0
  33. package/dist/ru/parts/profile/ui/password-change-marshal/index.js +32 -0
  34. package/package.json +3 -3
@@ -165,6 +165,12 @@
165
165
  margin-top: 1.5rem;
166
166
  }
167
167
 
168
+ .topline_profile_subtitle {
169
+ margin: 0;
170
+ font-size: 1.2em;
171
+ text-align: center;
172
+ }
173
+
168
174
  .topline_profile_buttonsCont {
169
175
  margin-top: 2rem;
170
176
  display: flex;
@@ -172,6 +178,16 @@
172
178
  gap: 1rem;
173
179
  }
174
180
 
181
+ .topline_profile_confirmationMesage {
182
+ text-align: center;
183
+ }
184
+
185
+ .topline_profile_confirmationButtonsCont {
186
+ margin-top: 2rem;
187
+ display: flex;
188
+ justify-content: center;
189
+ }
190
+
175
191
  .topline_c_modal_overlay {
176
192
  position: fixed;
177
193
  z-index: 1;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create new beacon for a password change operation.
3
+ *
4
+ * Saves the created beacon in the local store.
5
+ */
6
+ export declare function createPasswordChangeBeacon(): string;
7
+ /**
8
+ * Check whether given beacon exists in the local store.
9
+ */
10
+ export declare function passwordChangeBeaconExists(beacon: string): boolean;
11
+ /**
12
+ * Remove given beacon from the local store
13
+ */
14
+ export declare function removePasswordChangeBeacon(beacon: string): void;
@@ -0,0 +1,25 @@
1
+ import { v4 } from 'uuid';
2
+ // Local store
3
+ const passwordChangeBeacons = new Set();
4
+ /**
5
+ * Create new beacon for a password change operation.
6
+ *
7
+ * Saves the created beacon in the local store.
8
+ */
9
+ export function createPasswordChangeBeacon() {
10
+ const beacon = v4();
11
+ passwordChangeBeacons.add(beacon);
12
+ return beacon;
13
+ }
14
+ /**
15
+ * Check whether given beacon exists in the local store.
16
+ */
17
+ export function passwordChangeBeaconExists(beacon) {
18
+ return passwordChangeBeacons.has(beacon);
19
+ }
20
+ /**
21
+ * Remove given beacon from the local store
22
+ */
23
+ export function removePasswordChangeBeacon(beacon) {
24
+ passwordChangeBeacons.delete(beacon);
25
+ }
@@ -19,8 +19,7 @@ import shouldRetry from './should-retry.js';
19
19
  * @param fetchApiOpts Fetch API options
20
20
  * @param requestOpts Custom options
21
21
  */
22
- export default async function request(url, fetchApiOpts) {
23
- let requestOpts = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
22
+ export default async function request(url, fetchApiOpts, requestOpts = {}) {
24
23
  const {
25
24
  expectedStatusCodes,
26
25
  timeoutMs,
@@ -17,11 +17,11 @@ export class InnerService {
17
17
  #updateRefreshTokenInDb;
18
18
  #logError;
19
19
  /** Registered outer event listeners */
20
- #listeners = (() => new Set())();
20
+ #listeners = new Set();
21
21
  /** Registered inner "log-in" event listeners */
22
- #innerLogInListeners = (() => new Set())();
22
+ #innerLogInListeners = new Set();
23
23
  /** Registered inner "create-account" event listeners */
24
- #innerCreateAccountListeners = (() => new Set())();
24
+ #innerCreateAccountListeners = new Set();
25
25
  constructor(deps) {
26
26
  this.#getRefreshToken = deps.getRefreshToken;
27
27
  this.#setGrantToken = deps.setGrantToken;
@@ -6,6 +6,7 @@ import { innerToplineService, ToplineEventName } from '../topline-service/index.
6
6
  import { isObject } from '../../types/helpers.js';
7
7
  import { isToplineUser } from '../../types/data.js';
8
8
  import { userDataChangeBeaconExists, removeUserDataChangeBeacon } from '../user-data-change-beacon/index.js';
9
+ import { passwordChangeBeaconExists, removePasswordChangeBeacon } from '../password-change-beacon/index.js';
9
10
  import { updateUser } from '../local-db/index.js';
10
11
  let activeSocket = null;
11
12
  let currConnectionRetries = 0;
@@ -29,8 +30,12 @@ function handleSocketMessage(e) {
29
30
  if (isObject(message) && 'type' in message && typeof message.type === 'string') {
30
31
  switch (message.type) {
31
32
  case 'logout':
32
- innerToplineService.processLogout();
33
- closeWebsocket();
33
+ if ('beacon' in message && typeof message.beacon === 'string' && passwordChangeBeaconExists(message.beacon)) {
34
+ removePasswordChangeBeacon(message.beacon);
35
+ } else {
36
+ innerToplineService.processLogout();
37
+ closeWebsocket();
38
+ }
34
39
  break;
35
40
  case 'sync':
36
41
  if ('syncId' in message && typeof message.syncId === 'string') {
@@ -39,8 +44,9 @@ function handleSocketMessage(e) {
39
44
  break;
40
45
  case 'user_data_change':
41
46
  if ('beacon' in message && typeof message.beacon === 'string' && 'user' in message && isToplineUser(message.user)) {
42
- if (!userDataChangeBeaconExists(message.beacon)) {
47
+ if (userDataChangeBeaconExists(message.beacon)) {
43
48
  removeUserDataChangeBeacon(message.beacon);
49
+ } else {
44
50
  void updateUser(message.user);
45
51
  innerToplineService.emit(ToplineEventName.UserChange, message.user);
46
52
  }
@@ -0,0 +1,16 @@
1
+ export declare enum PasswordChangeResult {
2
+ Success = "success",
3
+ NetworkFailure = "network_failure",
4
+ /**
5
+ * Old password is incorrect. This case might occure when:
6
+ * (1) user makes an error while typing the old password, or
7
+ * (2) user doesn't remember their current password and tries a wrong one, or
8
+ * (3) user has changed the password in another instance of the app, the `logout`
9
+ * message has not been received on a websocket (for any reason), and
10
+ * the current `grant token` has not expired yet.
11
+ */
12
+ IncorrectOldPassword = "incorrect_old_password",
13
+ /** HTTP request is not authenticated with `refresh token`, or user no longer exists. */
14
+ Unauthorized = "unauthorized"
15
+ }
16
+ export default function changePassword(beacon: string, oldPassword: string, newPassword: string): Promise<PasswordChangeResult>;
@@ -0,0 +1,102 @@
1
+ import WebError from '@memnrev/web-error';
2
+ import { getGrantToken, setGrantToken, setRefreshToken } from '../../../../modules/tokens/index.js';
3
+ import { logInvariant, logError } from '../../../../modules/logger/index.js';
4
+ import { getApiBaseUrl, getRequestCredentialsMode } from '../../../../modules/env/index.js';
5
+ import request from '../../../../modules/request/index.js';
6
+ import { innerToplineService } from '../../../../modules/topline-service/index.js';
7
+ import { updateGrantToken, updateRefreshToken } from '../../../../modules/local-db/index.js';
8
+ export var PasswordChangeResult;
9
+ (function (PasswordChangeResult) {
10
+ PasswordChangeResult["Success"] = "success";
11
+ PasswordChangeResult["NetworkFailure"] = "network_failure";
12
+ /**
13
+ * Old password is incorrect. This case might occure when:
14
+ * (1) user makes an error while typing the old password, or
15
+ * (2) user doesn't remember their current password and tries a wrong one, or
16
+ * (3) user has changed the password in another instance of the app, the `logout`
17
+ * message has not been received on a websocket (for any reason), and
18
+ * the current `grant token` has not expired yet.
19
+ */
20
+ PasswordChangeResult["IncorrectOldPassword"] = "incorrect_old_password";
21
+ /** HTTP request is not authenticated with `refresh token`, or user no longer exists. */
22
+ PasswordChangeResult["Unauthorized"] = "unauthorized";
23
+ })(PasswordChangeResult || (PasswordChangeResult = {}));
24
+ export default async function changePassword(beacon, oldPassword, newPassword) {
25
+ const grantToken = getGrantToken();
26
+ if (!grantToken) {
27
+ // This situation is impossible: if a logout event is received on a websocket,
28
+ // this modal window is closed automatically; thus the user has no chance to start
29
+ // a saving operation.
30
+ logInvariant('Invariant: grant token must exist while changing password');
31
+ return PasswordChangeResult.Unauthorized;
32
+ }
33
+ const url = `${getApiBaseUrl()}/private/user/change-password`;
34
+ const options = {
35
+ method: 'POST',
36
+ headers: {
37
+ Authorization: `Bearer ${grantToken}`,
38
+ 'Content-Type': 'application/json; charset=utf-8'
39
+ },
40
+ body: JSON.stringify({
41
+ beacon,
42
+ currPassword: oldPassword,
43
+ newPassword
44
+ }),
45
+ credentials: getRequestCredentialsMode(),
46
+ cache: 'no-store',
47
+ redirect: 'error'
48
+ };
49
+ const response = await request(url, options, {
50
+ // Explicitly avoid retries as this operation is not idempotent
51
+ maxRetries: 0
52
+ });
53
+ if (!response) {
54
+ return PasswordChangeResult.NetworkFailure;
55
+ }
56
+ if (response.status === 401) {
57
+ const upgradedGrantToken = await innerToplineService.upgradeGrantToken();
58
+ if (!upgradedGrantToken) {
59
+ innerToplineService.processLogout();
60
+ return PasswordChangeResult.Unauthorized;
61
+ }
62
+ return await changePassword(beacon, oldPassword, newPassword);
63
+ }
64
+ let payload;
65
+ try {
66
+ payload = await response.json();
67
+ } catch (e) {
68
+ logError(new WebError({
69
+ name: 'UnexpectedError',
70
+ ...(e instanceof Error ? {
71
+ cause: e
72
+ } : {})
73
+ }, `parsing 200 response of request to ${url} failed`));
74
+ return PasswordChangeResult.NetworkFailure;
75
+ }
76
+ const {
77
+ result
78
+ } = payload;
79
+ switch (result) {
80
+ case 'OK':
81
+ {
82
+ const {
83
+ grantToken,
84
+ refreshToken
85
+ } = payload.tokens;
86
+ setGrantToken(grantToken);
87
+ setRefreshToken(refreshToken);
88
+ await Promise.all([updateGrantToken(grantToken), updateRefreshToken(refreshToken)]);
89
+ return PasswordChangeResult.Success;
90
+ }
91
+ case 'NO_USER':
92
+ innerToplineService.processLogout();
93
+ return PasswordChangeResult.Unauthorized;
94
+ case 'INVALID_PASSWORD':
95
+ return PasswordChangeResult.IncorrectOldPassword;
96
+ default:
97
+ {
98
+ const _exhaustiveCheck = result;
99
+ return _exhaustiveCheck;
100
+ }
101
+ }
102
+ }
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useContext, useState, useCallback } from 'react';
3
3
  import Modal from '../../../shared/components/modal/index.js';
4
4
  import Root from './root/index.js';
5
+ import PasswordChangeMarshal from './password-change-marshal/index.js';
5
6
  import { ToplineContext } from '../../shell/index.js';
6
7
  var LocalStep;
7
8
  (function (LocalStep) {
@@ -17,6 +18,7 @@ export default function Profile(props) {
17
18
  } = useContext(ToplineContext);
18
19
  const [localStep, setLocalStep] = useState(LocalStep.Root);
19
20
  const handleSwitchToPasswordChange = useCallback(() => setLocalStep(LocalStep.PasswordChange), []);
21
+ const handleReturningFromPasswordChange = useCallback(() => setLocalStep(LocalStep.Root), []);
20
22
  const getContents = user => {
21
23
  switch (localStep) {
22
24
  case LocalStep.Root:
@@ -26,8 +28,8 @@ export default function Profile(props) {
26
28
  onCancel: onClose
27
29
  });
28
30
  case LocalStep.PasswordChange:
29
- return _jsx("div", {
30
- children: "PasswordChange"
31
+ return _jsx(PasswordChangeMarshal, {
32
+ onCancel: handleReturningFromPasswordChange
31
33
  });
32
34
  default:
33
35
  {
@@ -0,0 +1,6 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onOk: () => unknown;
4
+ };
5
+ export default function PasswordChangeConfirmation(props: Props): JSX.Element;
6
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from '@peassoft/mnr-web-ui-kit/button/index.js';
3
+ export default function PasswordChangeConfirmation(props) {
4
+ const {
5
+ onOk
6
+ } = props;
7
+ return _jsxs(_Fragment, {
8
+ children: [_jsx("h2", {
9
+ className: 'topline_profile_subtitle',
10
+ children: "Password Change"
11
+ }), _jsx("p", {
12
+ className: 'topline_profile_confirmationMesage',
13
+ children: "You password has successfully been changed."
14
+ }), _jsx("div", {
15
+ className: 'topline_profile_confirmationButtonsCont',
16
+ children: _jsx(Button, {
17
+ label: 'OK',
18
+ onClick: onOk
19
+ })
20
+ })]
21
+ });
22
+ }
@@ -0,0 +1,7 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onSuccess: () => unknown;
4
+ onCancel: () => unknown;
5
+ };
6
+ export default function PasswordChangeForm(props: Props): JSX.Element;
7
+ export {};
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useMemo } from 'react';
3
+ import { InputField } from '@peassoft/mnr-web-ui-kit/input-field/index.js';
4
+ import { Button } from '@peassoft/mnr-web-ui-kit/button/index.js';
5
+ import { ErrorMessage } from '@peassoft/mnr-web-ui-kit/error-message/index.js';
6
+ import useStateWithValidationReset from './use-state-with-validation-reset.js';
7
+ import changePassword, { PasswordChangeResult } from '../../actions/change-password/index.js';
8
+ import { createPasswordChangeBeacon } from '../../../../modules/password-change-beacon/index.js';
9
+ var NewPasswordConfirmationError;
10
+ (function (NewPasswordConfirmationError) {
11
+ NewPasswordConfirmationError["None"] = "none";
12
+ NewPasswordConfirmationError["Empty"] = "empty";
13
+ NewPasswordConfirmationError["NotMatching"] = "not_matching";
14
+ })(NewPasswordConfirmationError || (NewPasswordConfirmationError = {}));
15
+ export default function PasswordChangeForm(props) {
16
+ const {
17
+ onSuccess,
18
+ onCancel
19
+ } = props;
20
+ const [isInProgress, setIsInProgress] = useState(false);
21
+ const [hasOldPasswordValidationError, setHasOldPasswordValidationError] = useState(false);
22
+ const [hasNewPasswordValidationError, setHasNewPasswordValidationError] = useState(false);
23
+ const [newPasswordConfirmationError, setNewPasswordConfirmationError] = useState(NewPasswordConfirmationError.None);
24
+ const [oldPassword, setOldPassword] = useStateWithValidationReset('', setHasOldPasswordValidationError, false);
25
+ const [newPassword, setNewPassword] = useStateWithValidationReset('', setHasNewPasswordValidationError, false);
26
+ const [newPasswordConfirmation, setNewPasswordConfirmation] = useStateWithValidationReset('', setNewPasswordConfirmationError, NewPasswordConfirmationError.None);
27
+ const [savingError, setSavingError] = useState('');
28
+ const newPasswordConfirmationErrorMessage = useMemo(() => {
29
+ switch (newPasswordConfirmationError) {
30
+ case NewPasswordConfirmationError.None:
31
+ return '';
32
+ case NewPasswordConfirmationError.Empty:
33
+ return "Re-entering new password is required.";
34
+ case NewPasswordConfirmationError.NotMatching:
35
+ return "Passwords do not match.";
36
+ default:
37
+ {
38
+ const _exhaustiveCheck = newPasswordConfirmationError;
39
+ return _exhaustiveCheck;
40
+ }
41
+ }
42
+ }, [newPasswordConfirmationError]);
43
+ const validateInput = useCallback(() => {
44
+ let isCorrect = true;
45
+ if (!oldPassword) {
46
+ isCorrect = false;
47
+ setHasOldPasswordValidationError(true);
48
+ }
49
+ if (!newPassword) {
50
+ isCorrect = false;
51
+ setHasNewPasswordValidationError(true);
52
+ }
53
+ if (!newPasswordConfirmation) {
54
+ isCorrect = false;
55
+ setNewPasswordConfirmationError(NewPasswordConfirmationError.Empty);
56
+ } else if (newPasswordConfirmation !== newPassword) {
57
+ isCorrect = false;
58
+ setNewPasswordConfirmationError(NewPasswordConfirmationError.NotMatching);
59
+ }
60
+ return isCorrect;
61
+ }, [oldPassword, newPassword, newPasswordConfirmation]);
62
+ const resetErrors = useCallback(() => {
63
+ setHasOldPasswordValidationError(false);
64
+ setHasNewPasswordValidationError(false);
65
+ setNewPasswordConfirmationError(NewPasswordConfirmationError.None);
66
+ setSavingError('');
67
+ }, []);
68
+ const handleSave = useCallback(async () => {
69
+ if (!validateInput()) return;
70
+ resetErrors();
71
+ setIsInProgress(true);
72
+ const beacon = createPasswordChangeBeacon();
73
+ const result = await changePassword(beacon, oldPassword, newPassword);
74
+ switch (result) {
75
+ case PasswordChangeResult.Success:
76
+ onSuccess();
77
+ break;
78
+ case PasswordChangeResult.NetworkFailure:
79
+ setSavingError("An error occurred while saving the new password. Check your internet connection and/or try again.");
80
+ setIsInProgress(false);
81
+ break;
82
+ case PasswordChangeResult.IncorrectOldPassword:
83
+ setSavingError("The current password is incorrect.");
84
+ setIsInProgress(false);
85
+ break;
86
+ case PasswordChangeResult.Unauthorized:
87
+ // Do nothing here. Everything needed has already been done by the action.
88
+ break;
89
+ default:
90
+ {
91
+ const _exhaustiveCheck = result;
92
+ return _exhaustiveCheck;
93
+ }
94
+ }
95
+ }, [validateInput, resetErrors, onSuccess, oldPassword, newPassword]);
96
+ return _jsxs("form", {
97
+ children: [_jsx("h2", {
98
+ className: 'topline_profile_subtitle',
99
+ children: "Password Change"
100
+ }), _jsx(InputField, {
101
+ type: 'password',
102
+ autocompleteAttribute: 'current-password',
103
+ label: "Your current password. Required.",
104
+ value: oldPassword,
105
+ errorMessage: hasOldPasswordValidationError ? "Enter your current password." : undefined,
106
+ onChange: setOldPassword
107
+ }), _jsx(InputField, {
108
+ type: 'password',
109
+ autocompleteAttribute: 'new-password',
110
+ label: "New password. Required.",
111
+ value: newPassword,
112
+ errorMessage: hasNewPasswordValidationError ? "Enter a new password." : undefined,
113
+ onChange: setNewPassword
114
+ }), _jsx(InputField, {
115
+ type: 'password',
116
+ label: "Enter the new password once again. Required.",
117
+ value: newPasswordConfirmation,
118
+ errorMessage: newPasswordConfirmationErrorMessage || undefined,
119
+ onChange: setNewPasswordConfirmation
120
+ }), !!savingError && _jsx(ErrorMessage, {
121
+ variant: 'standalone',
122
+ children: savingError
123
+ }), _jsxs("div", {
124
+ className: 'topline_profile_buttonsCont',
125
+ children: [_jsx(Button, {
126
+ label: "Save new password",
127
+ isInProgress: isInProgress,
128
+ onClick: handleSave
129
+ }), !isInProgress && _jsx(Button, {
130
+ label: "Cancel",
131
+ variant: 'secondary',
132
+ onClick: onCancel
133
+ })]
134
+ })]
135
+ });
136
+ }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export default function useStateWithValidationReset<T, V>(initialState: T, validationSetFn: React.Dispatch<React.SetStateAction<V>>, validationResetValue: V): [T, (newValue: T) => void];
@@ -0,0 +1,9 @@
1
+ import { useState, useCallback } from 'react';
2
+ export default function useStateWithValidationReset(initialState, validationSetFn, validationResetValue) {
3
+ const [value, setValue] = useState(initialState);
4
+ const handleChange = useCallback(newVal => {
5
+ validationSetFn(validationResetValue);
6
+ setValue(newVal);
7
+ }, [validationSetFn, validationResetValue]);
8
+ return [value, handleChange];
9
+ }
@@ -0,0 +1,6 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onCancel: () => unknown;
4
+ };
5
+ export default function PasswordChangeMarshal(props: Props): JSX.Element;
6
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useCallback } from 'react';
3
+ import PasswordChangeForm from '../password-change-form/index.js';
4
+ import PasswordChangeConfirmation from '../password-change-confirmation/index.js';
5
+ var LocalStep;
6
+ (function (LocalStep) {
7
+ LocalStep["Form"] = "form";
8
+ LocalStep["SuccessConfirmation"] = "success_confirmation";
9
+ })(LocalStep || (LocalStep = {}));
10
+ export default function PasswordChangeMarshal(props) {
11
+ const {
12
+ onCancel
13
+ } = props;
14
+ const [localStep, setLocalStep] = useState(LocalStep.Form);
15
+ const handleSuccess = useCallback(() => setLocalStep(LocalStep.SuccessConfirmation), []);
16
+ switch (localStep) {
17
+ case LocalStep.Form:
18
+ return _jsx(PasswordChangeForm, {
19
+ onSuccess: handleSuccess,
20
+ onCancel: onCancel
21
+ });
22
+ case LocalStep.SuccessConfirmation:
23
+ return _jsx(PasswordChangeConfirmation, {
24
+ onOk: onCancel
25
+ });
26
+ default:
27
+ {
28
+ const _exhaustiveCheck = localStep;
29
+ return _exhaustiveCheck;
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create new beacon for a password change operation.
3
+ *
4
+ * Saves the created beacon in the local store.
5
+ */
6
+ export declare function createPasswordChangeBeacon(): string;
7
+ /**
8
+ * Check whether given beacon exists in the local store.
9
+ */
10
+ export declare function passwordChangeBeaconExists(beacon: string): boolean;
11
+ /**
12
+ * Remove given beacon from the local store
13
+ */
14
+ export declare function removePasswordChangeBeacon(beacon: string): void;
@@ -0,0 +1,25 @@
1
+ import { v4 } from 'uuid';
2
+ // Local store
3
+ const passwordChangeBeacons = new Set();
4
+ /**
5
+ * Create new beacon for a password change operation.
6
+ *
7
+ * Saves the created beacon in the local store.
8
+ */
9
+ export function createPasswordChangeBeacon() {
10
+ const beacon = v4();
11
+ passwordChangeBeacons.add(beacon);
12
+ return beacon;
13
+ }
14
+ /**
15
+ * Check whether given beacon exists in the local store.
16
+ */
17
+ export function passwordChangeBeaconExists(beacon) {
18
+ return passwordChangeBeacons.has(beacon);
19
+ }
20
+ /**
21
+ * Remove given beacon from the local store
22
+ */
23
+ export function removePasswordChangeBeacon(beacon) {
24
+ passwordChangeBeacons.delete(beacon);
25
+ }
@@ -19,8 +19,7 @@ import shouldRetry from './should-retry.js';
19
19
  * @param fetchApiOpts Fetch API options
20
20
  * @param requestOpts Custom options
21
21
  */
22
- export default async function request(url, fetchApiOpts) {
23
- let requestOpts = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
22
+ export default async function request(url, fetchApiOpts, requestOpts = {}) {
24
23
  const {
25
24
  expectedStatusCodes,
26
25
  timeoutMs,
@@ -17,11 +17,11 @@ export class InnerService {
17
17
  #updateRefreshTokenInDb;
18
18
  #logError;
19
19
  /** Registered outer event listeners */
20
- #listeners = (() => new Set())();
20
+ #listeners = new Set();
21
21
  /** Registered inner "log-in" event listeners */
22
- #innerLogInListeners = (() => new Set())();
22
+ #innerLogInListeners = new Set();
23
23
  /** Registered inner "create-account" event listeners */
24
- #innerCreateAccountListeners = (() => new Set())();
24
+ #innerCreateAccountListeners = new Set();
25
25
  constructor(deps) {
26
26
  this.#getRefreshToken = deps.getRefreshToken;
27
27
  this.#setGrantToken = deps.setGrantToken;
@@ -6,6 +6,7 @@ import { innerToplineService, ToplineEventName } from '../topline-service/index.
6
6
  import { isObject } from '../../types/helpers.js';
7
7
  import { isToplineUser } from '../../types/data.js';
8
8
  import { userDataChangeBeaconExists, removeUserDataChangeBeacon } from '../user-data-change-beacon/index.js';
9
+ import { passwordChangeBeaconExists, removePasswordChangeBeacon } from '../password-change-beacon/index.js';
9
10
  import { updateUser } from '../local-db/index.js';
10
11
  let activeSocket = null;
11
12
  let currConnectionRetries = 0;
@@ -29,8 +30,12 @@ function handleSocketMessage(e) {
29
30
  if (isObject(message) && 'type' in message && typeof message.type === 'string') {
30
31
  switch (message.type) {
31
32
  case 'logout':
32
- innerToplineService.processLogout();
33
- closeWebsocket();
33
+ if ('beacon' in message && typeof message.beacon === 'string' && passwordChangeBeaconExists(message.beacon)) {
34
+ removePasswordChangeBeacon(message.beacon);
35
+ } else {
36
+ innerToplineService.processLogout();
37
+ closeWebsocket();
38
+ }
34
39
  break;
35
40
  case 'sync':
36
41
  if ('syncId' in message && typeof message.syncId === 'string') {
@@ -39,8 +44,9 @@ function handleSocketMessage(e) {
39
44
  break;
40
45
  case 'user_data_change':
41
46
  if ('beacon' in message && typeof message.beacon === 'string' && 'user' in message && isToplineUser(message.user)) {
42
- if (!userDataChangeBeaconExists(message.beacon)) {
47
+ if (userDataChangeBeaconExists(message.beacon)) {
43
48
  removeUserDataChangeBeacon(message.beacon);
49
+ } else {
44
50
  void updateUser(message.user);
45
51
  innerToplineService.emit(ToplineEventName.UserChange, message.user);
46
52
  }
@@ -0,0 +1,16 @@
1
+ export declare enum PasswordChangeResult {
2
+ Success = "success",
3
+ NetworkFailure = "network_failure",
4
+ /**
5
+ * Old password is incorrect. This case might occure when:
6
+ * (1) user makes an error while typing the old password, or
7
+ * (2) user doesn't remember their current password and tries a wrong one, or
8
+ * (3) user has changed the password in another instance of the app, the `logout`
9
+ * message has not been received on a websocket (for any reason), and
10
+ * the current `grant token` has not expired yet.
11
+ */
12
+ IncorrectOldPassword = "incorrect_old_password",
13
+ /** HTTP request is not authenticated with `refresh token`, or user no longer exists. */
14
+ Unauthorized = "unauthorized"
15
+ }
16
+ export default function changePassword(beacon: string, oldPassword: string, newPassword: string): Promise<PasswordChangeResult>;
@@ -0,0 +1,102 @@
1
+ import WebError from '@memnrev/web-error';
2
+ import { getGrantToken, setGrantToken, setRefreshToken } from '../../../../modules/tokens/index.js';
3
+ import { logInvariant, logError } from '../../../../modules/logger/index.js';
4
+ import { getApiBaseUrl, getRequestCredentialsMode } from '../../../../modules/env/index.js';
5
+ import request from '../../../../modules/request/index.js';
6
+ import { innerToplineService } from '../../../../modules/topline-service/index.js';
7
+ import { updateGrantToken, updateRefreshToken } from '../../../../modules/local-db/index.js';
8
+ export var PasswordChangeResult;
9
+ (function (PasswordChangeResult) {
10
+ PasswordChangeResult["Success"] = "success";
11
+ PasswordChangeResult["NetworkFailure"] = "network_failure";
12
+ /**
13
+ * Old password is incorrect. This case might occure when:
14
+ * (1) user makes an error while typing the old password, or
15
+ * (2) user doesn't remember their current password and tries a wrong one, or
16
+ * (3) user has changed the password in another instance of the app, the `logout`
17
+ * message has not been received on a websocket (for any reason), and
18
+ * the current `grant token` has not expired yet.
19
+ */
20
+ PasswordChangeResult["IncorrectOldPassword"] = "incorrect_old_password";
21
+ /** HTTP request is not authenticated with `refresh token`, or user no longer exists. */
22
+ PasswordChangeResult["Unauthorized"] = "unauthorized";
23
+ })(PasswordChangeResult || (PasswordChangeResult = {}));
24
+ export default async function changePassword(beacon, oldPassword, newPassword) {
25
+ const grantToken = getGrantToken();
26
+ if (!grantToken) {
27
+ // This situation is impossible: if a logout event is received on a websocket,
28
+ // this modal window is closed automatically; thus the user has no chance to start
29
+ // a saving operation.
30
+ logInvariant('Invariant: grant token must exist while changing password');
31
+ return PasswordChangeResult.Unauthorized;
32
+ }
33
+ const url = `${getApiBaseUrl()}/private/user/change-password`;
34
+ const options = {
35
+ method: 'POST',
36
+ headers: {
37
+ Authorization: `Bearer ${grantToken}`,
38
+ 'Content-Type': 'application/json; charset=utf-8'
39
+ },
40
+ body: JSON.stringify({
41
+ beacon,
42
+ currPassword: oldPassword,
43
+ newPassword
44
+ }),
45
+ credentials: getRequestCredentialsMode(),
46
+ cache: 'no-store',
47
+ redirect: 'error'
48
+ };
49
+ const response = await request(url, options, {
50
+ // Explicitly avoid retries as this operation is not idempotent
51
+ maxRetries: 0
52
+ });
53
+ if (!response) {
54
+ return PasswordChangeResult.NetworkFailure;
55
+ }
56
+ if (response.status === 401) {
57
+ const upgradedGrantToken = await innerToplineService.upgradeGrantToken();
58
+ if (!upgradedGrantToken) {
59
+ innerToplineService.processLogout();
60
+ return PasswordChangeResult.Unauthorized;
61
+ }
62
+ return await changePassword(beacon, oldPassword, newPassword);
63
+ }
64
+ let payload;
65
+ try {
66
+ payload = await response.json();
67
+ } catch (e) {
68
+ logError(new WebError({
69
+ name: 'UnexpectedError',
70
+ ...(e instanceof Error ? {
71
+ cause: e
72
+ } : {})
73
+ }, `parsing 200 response of request to ${url} failed`));
74
+ return PasswordChangeResult.NetworkFailure;
75
+ }
76
+ const {
77
+ result
78
+ } = payload;
79
+ switch (result) {
80
+ case 'OK':
81
+ {
82
+ const {
83
+ grantToken,
84
+ refreshToken
85
+ } = payload.tokens;
86
+ setGrantToken(grantToken);
87
+ setRefreshToken(refreshToken);
88
+ await Promise.all([updateGrantToken(grantToken), updateRefreshToken(refreshToken)]);
89
+ return PasswordChangeResult.Success;
90
+ }
91
+ case 'NO_USER':
92
+ innerToplineService.processLogout();
93
+ return PasswordChangeResult.Unauthorized;
94
+ case 'INVALID_PASSWORD':
95
+ return PasswordChangeResult.IncorrectOldPassword;
96
+ default:
97
+ {
98
+ const _exhaustiveCheck = result;
99
+ return _exhaustiveCheck;
100
+ }
101
+ }
102
+ }
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useContext, useState, useCallback } from 'react';
3
3
  import Modal from '../../../shared/components/modal/index.js';
4
4
  import Root from './root/index.js';
5
+ import PasswordChangeMarshal from './password-change-marshal/index.js';
5
6
  import { ToplineContext } from '../../shell/index.js';
6
7
  var LocalStep;
7
8
  (function (LocalStep) {
@@ -17,6 +18,7 @@ export default function Profile(props) {
17
18
  } = useContext(ToplineContext);
18
19
  const [localStep, setLocalStep] = useState(LocalStep.Root);
19
20
  const handleSwitchToPasswordChange = useCallback(() => setLocalStep(LocalStep.PasswordChange), []);
21
+ const handleReturningFromPasswordChange = useCallback(() => setLocalStep(LocalStep.Root), []);
20
22
  const getContents = user => {
21
23
  switch (localStep) {
22
24
  case LocalStep.Root:
@@ -26,8 +28,8 @@ export default function Profile(props) {
26
28
  onCancel: onClose
27
29
  });
28
30
  case LocalStep.PasswordChange:
29
- return _jsx("div", {
30
- children: "PasswordChange"
31
+ return _jsx(PasswordChangeMarshal, {
32
+ onCancel: handleReturningFromPasswordChange
31
33
  });
32
34
  default:
33
35
  {
@@ -0,0 +1,6 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onOk: () => unknown;
4
+ };
5
+ export default function PasswordChangeConfirmation(props: Props): JSX.Element;
6
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from '@peassoft/mnr-web-ui-kit/button/index.js';
3
+ export default function PasswordChangeConfirmation(props) {
4
+ const {
5
+ onOk
6
+ } = props;
7
+ return _jsxs(_Fragment, {
8
+ children: [_jsx("h2", {
9
+ className: 'topline_profile_subtitle',
10
+ children: "Смена пароля"
11
+ }), _jsx("p", {
12
+ className: 'topline_profile_confirmationMesage',
13
+ children: "Ваш пароль успешно изменён."
14
+ }), _jsx("div", {
15
+ className: 'topline_profile_confirmationButtonsCont',
16
+ children: _jsx(Button, {
17
+ label: 'OK',
18
+ onClick: onOk
19
+ })
20
+ })]
21
+ });
22
+ }
@@ -0,0 +1,7 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onSuccess: () => unknown;
4
+ onCancel: () => unknown;
5
+ };
6
+ export default function PasswordChangeForm(props: Props): JSX.Element;
7
+ export {};
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useMemo } from 'react';
3
+ import { InputField } from '@peassoft/mnr-web-ui-kit/input-field/index.js';
4
+ import { Button } from '@peassoft/mnr-web-ui-kit/button/index.js';
5
+ import { ErrorMessage } from '@peassoft/mnr-web-ui-kit/error-message/index.js';
6
+ import useStateWithValidationReset from './use-state-with-validation-reset.js';
7
+ import changePassword, { PasswordChangeResult } from '../../actions/change-password/index.js';
8
+ import { createPasswordChangeBeacon } from '../../../../modules/password-change-beacon/index.js';
9
+ var NewPasswordConfirmationError;
10
+ (function (NewPasswordConfirmationError) {
11
+ NewPasswordConfirmationError["None"] = "none";
12
+ NewPasswordConfirmationError["Empty"] = "empty";
13
+ NewPasswordConfirmationError["NotMatching"] = "not_matching";
14
+ })(NewPasswordConfirmationError || (NewPasswordConfirmationError = {}));
15
+ export default function PasswordChangeForm(props) {
16
+ const {
17
+ onSuccess,
18
+ onCancel
19
+ } = props;
20
+ const [isInProgress, setIsInProgress] = useState(false);
21
+ const [hasOldPasswordValidationError, setHasOldPasswordValidationError] = useState(false);
22
+ const [hasNewPasswordValidationError, setHasNewPasswordValidationError] = useState(false);
23
+ const [newPasswordConfirmationError, setNewPasswordConfirmationError] = useState(NewPasswordConfirmationError.None);
24
+ const [oldPassword, setOldPassword] = useStateWithValidationReset('', setHasOldPasswordValidationError, false);
25
+ const [newPassword, setNewPassword] = useStateWithValidationReset('', setHasNewPasswordValidationError, false);
26
+ const [newPasswordConfirmation, setNewPasswordConfirmation] = useStateWithValidationReset('', setNewPasswordConfirmationError, NewPasswordConfirmationError.None);
27
+ const [savingError, setSavingError] = useState('');
28
+ const newPasswordConfirmationErrorMessage = useMemo(() => {
29
+ switch (newPasswordConfirmationError) {
30
+ case NewPasswordConfirmationError.None:
31
+ return '';
32
+ case NewPasswordConfirmationError.Empty:
33
+ return "Необходимо повторить ввод нового пароля.";
34
+ case NewPasswordConfirmationError.NotMatching:
35
+ return "Подтверждение нового пароля не совпадает.";
36
+ default:
37
+ {
38
+ const _exhaustiveCheck = newPasswordConfirmationError;
39
+ return _exhaustiveCheck;
40
+ }
41
+ }
42
+ }, [newPasswordConfirmationError]);
43
+ const validateInput = useCallback(() => {
44
+ let isCorrect = true;
45
+ if (!oldPassword) {
46
+ isCorrect = false;
47
+ setHasOldPasswordValidationError(true);
48
+ }
49
+ if (!newPassword) {
50
+ isCorrect = false;
51
+ setHasNewPasswordValidationError(true);
52
+ }
53
+ if (!newPasswordConfirmation) {
54
+ isCorrect = false;
55
+ setNewPasswordConfirmationError(NewPasswordConfirmationError.Empty);
56
+ } else if (newPasswordConfirmation !== newPassword) {
57
+ isCorrect = false;
58
+ setNewPasswordConfirmationError(NewPasswordConfirmationError.NotMatching);
59
+ }
60
+ return isCorrect;
61
+ }, [oldPassword, newPassword, newPasswordConfirmation]);
62
+ const resetErrors = useCallback(() => {
63
+ setHasOldPasswordValidationError(false);
64
+ setHasNewPasswordValidationError(false);
65
+ setNewPasswordConfirmationError(NewPasswordConfirmationError.None);
66
+ setSavingError('');
67
+ }, []);
68
+ const handleSave = useCallback(async () => {
69
+ if (!validateInput()) return;
70
+ resetErrors();
71
+ setIsInProgress(true);
72
+ const beacon = createPasswordChangeBeacon();
73
+ const result = await changePassword(beacon, oldPassword, newPassword);
74
+ switch (result) {
75
+ case PasswordChangeResult.Success:
76
+ onSuccess();
77
+ break;
78
+ case PasswordChangeResult.NetworkFailure:
79
+ setSavingError("При сохранении нового пароля произошла ошибка. Проверьте ваше подключение к интернету и/или попробуйте ещё раз.");
80
+ setIsInProgress(false);
81
+ break;
82
+ case PasswordChangeResult.IncorrectOldPassword:
83
+ setSavingError("Текущий пароль неверный.");
84
+ setIsInProgress(false);
85
+ break;
86
+ case PasswordChangeResult.Unauthorized:
87
+ // Do nothing here. Everything needed has already been done by the action.
88
+ break;
89
+ default:
90
+ {
91
+ const _exhaustiveCheck = result;
92
+ return _exhaustiveCheck;
93
+ }
94
+ }
95
+ }, [validateInput, resetErrors, onSuccess, oldPassword, newPassword]);
96
+ return _jsxs("form", {
97
+ children: [_jsx("h2", {
98
+ className: 'topline_profile_subtitle',
99
+ children: "Смена пароля"
100
+ }), _jsx(InputField, {
101
+ type: 'password',
102
+ autocompleteAttribute: 'current-password',
103
+ label: "Ваш текущий пароль. Обязательное поле.",
104
+ value: oldPassword,
105
+ errorMessage: hasOldPasswordValidationError ? "Введите ваш текущий пароль." : undefined,
106
+ onChange: setOldPassword
107
+ }), _jsx(InputField, {
108
+ type: 'password',
109
+ autocompleteAttribute: 'new-password',
110
+ label: "Новый пароль. Обязательное поле.",
111
+ value: newPassword,
112
+ errorMessage: hasNewPasswordValidationError ? "Введите новый пароль." : undefined,
113
+ onChange: setNewPassword
114
+ }), _jsx(InputField, {
115
+ type: 'password',
116
+ label: "Введите новый пароль ещё раз. Обязательное поле.",
117
+ value: newPasswordConfirmation,
118
+ errorMessage: newPasswordConfirmationErrorMessage || undefined,
119
+ onChange: setNewPasswordConfirmation
120
+ }), !!savingError && _jsx(ErrorMessage, {
121
+ variant: 'standalone',
122
+ children: savingError
123
+ }), _jsxs("div", {
124
+ className: 'topline_profile_buttonsCont',
125
+ children: [_jsx(Button, {
126
+ label: "Сохранить новый пароль",
127
+ isInProgress: isInProgress,
128
+ onClick: handleSave
129
+ }), !isInProgress && _jsx(Button, {
130
+ label: "Отменить",
131
+ variant: 'secondary',
132
+ onClick: onCancel
133
+ })]
134
+ })]
135
+ });
136
+ }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export default function useStateWithValidationReset<T, V>(initialState: T, validationSetFn: React.Dispatch<React.SetStateAction<V>>, validationResetValue: V): [T, (newValue: T) => void];
@@ -0,0 +1,9 @@
1
+ import { useState, useCallback } from 'react';
2
+ export default function useStateWithValidationReset(initialState, validationSetFn, validationResetValue) {
3
+ const [value, setValue] = useState(initialState);
4
+ const handleChange = useCallback(newVal => {
5
+ validationSetFn(validationResetValue);
6
+ setValue(newVal);
7
+ }, [validationSetFn, validationResetValue]);
8
+ return [value, handleChange];
9
+ }
@@ -0,0 +1,6 @@
1
+ import { type JSX } from 'react';
2
+ type Props = {
3
+ onCancel: () => unknown;
4
+ };
5
+ export default function PasswordChangeMarshal(props: Props): JSX.Element;
6
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useCallback } from 'react';
3
+ import PasswordChangeForm from '../password-change-form/index.js';
4
+ import PasswordChangeConfirmation from '../password-change-confirmation/index.js';
5
+ var LocalStep;
6
+ (function (LocalStep) {
7
+ LocalStep["Form"] = "form";
8
+ LocalStep["SuccessConfirmation"] = "success_confirmation";
9
+ })(LocalStep || (LocalStep = {}));
10
+ export default function PasswordChangeMarshal(props) {
11
+ const {
12
+ onCancel
13
+ } = props;
14
+ const [localStep, setLocalStep] = useState(LocalStep.Form);
15
+ const handleSuccess = useCallback(() => setLocalStep(LocalStep.SuccessConfirmation), []);
16
+ switch (localStep) {
17
+ case LocalStep.Form:
18
+ return _jsx(PasswordChangeForm, {
19
+ onSuccess: handleSuccess,
20
+ onCancel: onCancel
21
+ });
22
+ case LocalStep.SuccessConfirmation:
23
+ return _jsx(PasswordChangeConfirmation, {
24
+ onOk: onCancel
25
+ });
26
+ default:
27
+ {
28
+ const _exhaustiveCheck = localStep;
29
+ return _exhaustiveCheck;
30
+ }
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peassoft/mnr-web-topline",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Peassoft Topline widget for mem'n'rev web applications",
5
5
  "type": "module",
6
6
  "exports": {
@@ -38,7 +38,7 @@
38
38
  "@types/stampit": "^4.3.4",
39
39
  "autoprefixer": "^10.3.4",
40
40
  "clean-webpack-plugin": "^4.0.0",
41
- "copy-webpack-plugin": "^12.0.2",
41
+ "copy-webpack-plugin": "^13.0.0",
42
42
  "cpy-cli": "^5.0.0",
43
43
  "css-loader": "^7.1.2",
44
44
  "eslint": "^9.8.0",
@@ -65,7 +65,7 @@
65
65
  "@memnrev/web-error": "^0.3.0",
66
66
  "email-validator": "^2.0.4",
67
67
  "md5": "^2.3.0",
68
- "react-error-boundary": "^5.0.0",
68
+ "react-error-boundary": "^6.0.0",
69
69
  "stampit": "^4.3.2",
70
70
  "uuid": "^11.1.0"
71
71
  },