vimp-engine 0.37.0 → 0.38.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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/client/boot.js +1 -1
  3. package/src/client/components/controller/LobbyAuth.js +97 -15
  4. package/src/client/components/controller/Profile.js +85 -0
  5. package/src/client/components/model/Games.js +1 -1
  6. package/src/client/components/model/LobbyAuth.js +344 -115
  7. package/src/client/components/model/Profile.js +142 -0
  8. package/src/client/components/view/LobbyAuth.js +150 -62
  9. package/src/client/components/view/Profile.js +373 -0
  10. package/src/client/lib/authApi.js +97 -0
  11. package/src/client/lib/pkce.js +23 -0
  12. package/src/client/lib/roomLink.js +1 -1
  13. package/src/client/lib/signalingErrors.js +3 -3
  14. package/src/client/lib/turnstile.js +130 -0
  15. package/src/client/main.js +89 -24
  16. package/src/client/network/TokenRenewer.js +90 -0
  17. package/src/client/session/GuestSession.js +79 -19
  18. package/src/client/style.css +119 -16
  19. package/src/client/views/includes/lobby.pug +11 -6
  20. package/src/client/views/includes/lobbyAuth.pug +34 -16
  21. package/src/client/views/includes/profile.pug +73 -0
  22. package/src/client/views/index.pug +1 -0
  23. package/src/config/authClient.js +54 -30
  24. package/src/config/chatMessages.js +1 -2
  25. package/src/config/env.js +2 -2
  26. package/src/config/lobby.js +50 -1
  27. package/src/config/master.js +15 -8
  28. package/src/config/wsports.js +4 -1
  29. package/src/devtools/builtinScenario.js +1 -1
  30. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  31. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  32. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  33. package/src/devtools/invariants.js +1 -1
  34. package/src/devtools/pluginLoader.js +1 -1
  35. package/src/host/GameCoreAdapter.js +0 -3
  36. package/src/host/HostGame.js +28 -0
  37. package/src/host/PortMachine.js +50 -1
  38. package/src/host/meta/SocketManager.js +0 -13
  39. package/src/host/meta/core/RoundManager.js +0 -19
  40. package/src/host/meta/modules/PlayerDataSync.js +29 -3
  41. package/src/host/meta/modules/chat/systemMessages.js +1 -4
  42. package/src/lib/loadGamePackage.js +1 -1
  43. package/src/lib/validators.js +8 -0
  44. package/src/standalone/index.js +1 -1
@@ -4,23 +4,41 @@ import Publisher from '../../../lib/Publisher.js';
4
4
 
5
5
  let lobbyAuthView;
6
6
 
7
- const NICK_ERROR_MESSAGES = {
8
- nickTaken: 'This nick is already taken',
9
- invalidNick: 'Invalid nick',
10
- sessionExpired: 'Session expired, please sign in again',
11
- network: 'Network error, try again',
7
+ const REGISTER_ERRORS = {
8
+ invalidNick:
9
+ 'Invalid nick: 2–14 characters, starting with a letter. Letters, digits, spaces, "_" and "#" are allowed; the last character is a letter, digit or "_".',
10
+ nickTaken: 'This nick is already taken.',
11
+ turnstileBlocked:
12
+ 'Security check blocked. Please disable your ad blocker (or Brave Shields) and click Retry.',
13
+ turnstileRequired: 'Security check failed. Please try again.',
14
+ turnstileFailed: 'Security check failed. Please try again.',
15
+ turnstileUnavailable:
16
+ 'Registration is temporarily unavailable. Please try again later.',
17
+ rateLimited: 'Too many attempts. Please wait a minute and try again.',
18
+ termsRequired: 'The terms have been updated. Reload the page and try again.',
12
19
  };
13
20
 
14
- const LOGIN_ERROR_MESSAGES = {
15
- tokenExpired: 'Session expired, please sign in again',
16
- invalidToken: 'Session expired, please sign in again',
17
- oauthFailed: 'Sign-in failed, please try again',
21
+ const REGISTER_ERROR_FALLBACK = 'Network error. Please try again.';
22
+
23
+ const RESTORE_ERRORS = {
24
+ noLinkedProfile:
25
+ 'No profile is linked to this account. Create a profile first — you can link the account in Profile settings.',
26
+ denied: 'Sign-in was cancelled.',
27
+ };
28
+
29
+ const RESTORE_ERROR_FALLBACK = 'Sign-in failed. Please try again.';
30
+
31
+ const NOTICES = {
32
+ profileGone:
33
+ 'Your profile was not found — it may have been deleted after a period of inactivity.',
34
+ deleted: 'Your profile has been deleted.',
35
+ signedOut: 'You have signed out.',
18
36
  };
19
37
 
20
- // Представление логина лобби: переключает login/nick секции lobbyAuth.pug и
21
- // бейдж пользователя в lobby.pug (#lobby-user), скрывает #lobby целиком —
22
- // до авторизации список серверов недоступен (B2). Показывает лобби main.js,
23
- // когда маршрут в него ведёт: вход по ссылке минует лобби вовсе
38
+ // Представление экрана профиля: загрузка, отказ сервиса, Welcome и бейдж
39
+ // игрока в лобби (#lobby-user). Скрывает #lobby целиком, пока у вкладки нет
40
+ // личности; показывает лобби main.js, когда маршрут в него ведёт: вход по
41
+ // ссылке минует лобби вовсе. Текст в DOM — только через textContent.
24
42
  export default class LobbyAuthView {
25
43
  constructor(model, config) {
26
44
  if (lobbyAuthView) {
@@ -29,69 +47,104 @@ export default class LobbyAuthView {
29
47
 
30
48
  lobbyAuthView = this;
31
49
 
32
- const { elems, providerButtonClass } = config;
33
-
34
- this._elems = elems;
50
+ const { elems } = config;
51
+ const byId = id => document.getElementById(id);
52
+
53
+ this._model = model;
54
+ this._container = byId(elems.containerId);
55
+ this._status = byId(elems.statusId);
56
+ this._statusText = byId(elems.statusTextId);
57
+ this._statusRetry = byId(elems.statusRetryId);
58
+ this._welcome = byId(elems.welcomeId);
59
+ this._notice = byId(elems.noticeId);
60
+ this._nickInput = byId(elems.nickInputId);
61
+ this._nickError = byId(elems.nickErrorId);
62
+ this._turnstileRetry = byId(elems.turnstileRetryId);
63
+ this._turnstile = byId(elems.turnstileId);
64
+ this._register = byId(elems.registerId);
65
+ this._terms = byId(elems.termsId);
66
+ this._privacy = byId(elems.privacyId);
67
+ this._lifetime = byId(elems.lifetimeId);
68
+ this._restore = byId(elems.restoreId);
69
+ this._providers = byId(elems.providersId);
70
+ this._restoreError = byId(elems.restoreErrorId);
35
71
  this._providerButtons = document.querySelectorAll(
36
- `.${providerButtonClass}`,
72
+ `.${elems.providerClass}`,
37
73
  );
38
-
39
- this._container = document.getElementById(elems.containerId);
40
- this._loginSection = document.getElementById(elems.loginSectionId);
41
- this._loginError = document.getElementById(elems.loginErrorId);
42
- this._nickSection = document.getElementById(elems.nickSectionId);
43
- this._nickInput = document.getElementById(elems.nickInputId);
44
- this._nickError = document.getElementById(elems.nickErrorId);
45
- this._nickSubmit = document.getElementById(elems.nickSubmitId);
46
- this._lobby = document.getElementById(elems.lobbyId);
47
- this._user = document.getElementById(elems.userId);
48
- this._userNick = document.getElementById(elems.userNickId);
49
- this._userLogout = document.getElementById(elems.userLogoutId);
74
+ this._lobby = byId(elems.lobbyId);
75
+ this._user = byId(elems.userId);
76
+ this._userNick = byId(elems.userNickId);
50
77
 
51
78
  this.publisher = new Publisher();
52
79
 
53
- this._providerButtons.forEach(btn => {
54
- btn.onclick = () => this.publisher.emit('login', btn.dataset.provider);
55
- });
56
-
57
- this._nickSubmit.onclick = () => this._emitNick();
80
+ this._register.onclick = () => this._emitRegister();
58
81
  this._nickInput.onkeydown = e => {
59
82
  if (e.key === 'Enter') {
60
- this._emitNick();
83
+ this._emitRegister();
61
84
  }
62
85
  };
63
-
64
- this._userLogout.onclick = () => this.publisher.emit('logout');
86
+ this._restore.onclick = () => {
87
+ this._providers.style.display = 'block';
88
+ };
89
+ this._providerButtons.forEach(btn => {
90
+ btn.onclick = () => this.publisher.emit('restore', btn.dataset.provider);
91
+ });
92
+ this._statusRetry.onclick = () => this.publisher.emit('retry');
93
+ this._turnstileRetry.onclick = () => this.publisher.emit('turnstile-retry');
65
94
 
66
95
  const mp = model.publisher;
67
96
 
68
- mp.on('login-required', 'showLogin', this);
69
- mp.on('nick-required', 'showNick', this);
97
+ mp.on('loading', 'showLoading', this);
98
+ mp.on('service-error', 'showServiceError', this);
99
+ mp.on('welcome', 'showWelcome', this);
70
100
  mp.on('authenticated', 'showLobby', this);
71
- mp.on('login-error', 'renderLoginError', this);
72
- mp.on('nick-error', 'renderNickError', this);
101
+ mp.on('register-error', 'renderRegisterError', this);
102
+ mp.on('restore-error', 'renderRestoreError', this);
73
103
  }
74
104
 
75
- showLogin(providers = []) {
76
- this._container.style.display = 'block';
77
- this._loginSection.style.display = 'block';
78
- this._nickSection.style.display = 'none';
79
- this._lobby.style.display = 'none';
80
- this._user.style.display = 'none';
105
+ showLoading() {
106
+ this._showStatus('Loading…', false);
107
+ }
108
+
109
+ showServiceError() {
110
+ this._showStatus('The profile service is unavailable.', true);
111
+ }
112
+
113
+ _showStatus(text, retry) {
114
+ this._enterAuth();
115
+ this._status.style.display = 'block';
116
+ this._welcome.style.display = 'none';
117
+ this._statusText.textContent = text;
118
+ this._statusRetry.style.display = retry ? '' : 'none';
119
+ }
120
+
121
+ showWelcome({ notice = null } = {}) {
122
+ const clientConfig = this._model.getClientConfig();
123
+ const { providers } = clientConfig;
124
+
125
+ this._enterAuth();
126
+ this._status.style.display = 'none';
127
+ this._welcome.style.display = 'block';
128
+
129
+ this._notice.textContent = NOTICES[notice] ?? '';
130
+ this._terms.href = clientConfig.legal.termsUrl;
131
+ this._privacy.href = clientConfig.legal.privacyUrl;
132
+ this._lifetime.textContent =
133
+ 'Your profile is kept in this browser and is deleted after ' +
134
+ `${clientConfig.accounts.inactiveDays} days of inactivity. ` +
135
+ 'Link an account later to keep it longer.';
81
136
 
82
137
  this._providerButtons.forEach(btn => {
83
138
  btn.style.display = providers.includes(btn.dataset.provider)
84
139
  ? ''
85
140
  : 'none';
86
141
  });
87
- }
88
-
89
- showNick() {
90
- this._container.style.display = 'block';
91
- this._loginSection.style.display = 'none';
92
- this._nickSection.style.display = 'block';
142
+ this._restore.style.display = providers.length > 0 ? '' : 'none';
143
+ this._providers.style.display = 'none';
144
+ this._restoreError.textContent = '';
93
145
  this._nickError.textContent = '';
94
- this._nickInput.value = '';
146
+ this._turnstileRetry.style.display = 'none';
147
+
95
148
  this._nickInput.focus();
96
149
  }
97
150
 
@@ -101,17 +154,52 @@ export default class LobbyAuthView {
101
154
  this._userNick.textContent = nick;
102
155
  }
103
156
 
104
- renderLoginError(code) {
105
- this._loginError.textContent =
106
- LOGIN_ERROR_MESSAGES[code] || 'Sign-in failed';
157
+ // ошибка регистрации по коду; null снимает текст
158
+ renderRegisterError(code) {
159
+ this._nickError.textContent =
160
+ code === null ? '' : (REGISTER_ERRORS[code] ?? REGISTER_ERROR_FALLBACK);
107
161
  }
108
162
 
109
- renderNickError(code) {
110
- this._nickError.textContent =
111
- NICK_ERROR_MESSAGES[code] || 'Something went wrong';
163
+ renderRestoreError(code) {
164
+ this._providers.style.display = 'block';
165
+ this._restoreError.textContent =
166
+ RESTORE_ERRORS[code] ?? RESTORE_ERROR_FALLBACK;
167
+ }
168
+
169
+ // блокирует поле и кнопки на время запроса
170
+ setBusy(busy) {
171
+ this._nickInput.disabled = busy;
172
+ this._register.disabled = busy;
173
+ this._restore.disabled = busy;
174
+ this._providerButtons.forEach(btn => {
175
+ btn.disabled = busy;
176
+ });
177
+ }
178
+
179
+ getTurnstileContainer() {
180
+ return this._turnstile;
181
+ }
182
+
183
+ showTurnstileRetry(visible) {
184
+ this._turnstileRetry.style.display = visible ? '' : 'none';
185
+ }
186
+
187
+ getNickValue() {
188
+ return this._nickInput.value;
189
+ }
190
+
191
+ focusNick() {
192
+ this._nickInput.focus();
193
+ }
194
+
195
+ // экран профиля поверх скрытого лобби
196
+ _enterAuth() {
197
+ this._container.style.display = 'block';
198
+ this._lobby.style.display = 'none';
199
+ this._user.style.display = 'none';
112
200
  }
113
201
 
114
- _emitNick() {
115
- this.publisher.emit('nick', this._nickInput.value.trim());
202
+ _emitRegister() {
203
+ this.publisher.emit('register', this._nickInput.value);
116
204
  }
117
205
  }
@@ -0,0 +1,373 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton ProfileView
4
+
5
+ let profileView;
6
+
7
+ // Тексты ошибок по областям и кодам auth: модель кодов не переводит, вид не
8
+ // ходит в сеть. Язык интерфейса — английский, как у остального лобби
9
+ const GENERIC_ERROR = 'Something went wrong. Please try again.';
10
+
11
+ const LINK_ERRORS = {
12
+ identityInUse:
13
+ 'This account is already linked to another profile. To use that profile, delete this one and choose Restore profile on the Welcome screen.',
14
+ providerAlreadyLinked:
15
+ 'An account of this provider is already linked to your profile.',
16
+ denied: 'Linking was cancelled.',
17
+ };
18
+
19
+ const DELETE_ERRORS = {
20
+ confirmMismatch: 'The nick does not match.',
21
+ adminAccount: 'Admin profiles cannot be deleted.',
22
+ };
23
+
24
+ const AREA_ERRORS = {
25
+ link: code => LINK_ERRORS[code] ?? GENERIC_ERROR,
26
+ session: () => 'Could not sign out. Please try again.',
27
+ export: () => 'Could not prepare the file. Please try again.',
28
+ delete: code =>
29
+ DELETE_ERRORS[code] ?? 'Could not delete the profile. Please try again.',
30
+ };
31
+
32
+ const LINK_SUCCESS =
33
+ 'Account linked. You can now restore this profile in any browser.';
34
+ const NO_PROVIDERS = 'Account linking is not available on this server.';
35
+
36
+ // Представление панели профиля: статус, привязка аккаунтов с тремя
37
+ // согласиями, выход, выгрузка данных, удаление с вводом ника. В сеть не
38
+ // ходит и состояния не держит — рисует то, что пришло от модели, и
39
+ // публикует намерения пользователя. Текст в DOM — только через textContent.
40
+ //
41
+ // События: open, close, link <provider>, unlink <provider>, sign-out,
42
+ // export, delete <введённый ник>.
43
+ export default class ProfileView {
44
+ /**
45
+ * @param {Object} model - ProfileModel (источник событий и данных).
46
+ * @param {Object} config - Блок `profile` конфига лобби.
47
+ */
48
+ constructor(model, config) {
49
+ if (profileView) {
50
+ return profileView;
51
+ }
52
+
53
+ profileView = this;
54
+
55
+ const { elems } = config;
56
+ const byId = id => document.getElementById(id);
57
+
58
+ this._model = model;
59
+ this._config = config;
60
+ this._elems = elems;
61
+
62
+ this._panel = byId(elems.panelId);
63
+ this._lobby = byId(elems.lobbyId);
64
+ this._open = byId(elems.openBtnId);
65
+ this._close = byId(elems.closeBtnId);
66
+ this._nick = byId(elems.nickId);
67
+ this._status = byId(elems.statusId);
68
+ this._linkCard = byId(elems.linkCardId);
69
+ this._benefits = byId(elems.benefitsId);
70
+ this._providers = byId(elems.providersId);
71
+ this._consent = byId(elems.consentId);
72
+ this._checks = [
73
+ byId(elems.consentTermsId),
74
+ byId(elems.consentDataId),
75
+ byId(elems.consentAgeId),
76
+ ];
77
+ this._linkNotice = byId(elems.linkNoticeId);
78
+ this._linkError = byId(elems.linkErrorId);
79
+ this._sessionCard = byId(elems.sessionCardId);
80
+ this._signOut = byId(elems.signOutBtnId);
81
+ this._exportBtn = byId(elems.exportBtnId);
82
+ this._deleteAdminNote = byId(elems.deleteAdminNoteId);
83
+ this._deleteStart = byId(elems.deleteStartBtnId);
84
+ this._deleteConfirm = byId(elems.deleteConfirmId);
85
+ this._deleteNick = byId(elems.deleteNickId);
86
+ this._deleteInput = byId(elems.deleteInputId);
87
+ this._deleteSubmit = byId(elems.deleteSubmitBtnId);
88
+ this._deleteCancel = byId(elems.deleteCancelBtnId);
89
+
90
+ // строка ошибки каждой области — в своей карточке
91
+ this._errorLines = {
92
+ link: this._linkError,
93
+ session: byId(elems.sessionErrorId),
94
+ export: byId(elems.exportErrorId),
95
+ delete: byId(elems.deleteErrorId),
96
+ };
97
+
98
+ this._linkBtns = [];
99
+ this._unlinkBtns = [];
100
+ this._busy = false;
101
+
102
+ this.publisher = new Publisher();
103
+
104
+ this._open.onclick = () => this.publisher.emit('open');
105
+ this._close.onclick = () => this.hide();
106
+ this._signOut.onclick = () => this.publisher.emit('sign-out');
107
+ this._exportBtn.onclick = () => this.publisher.emit('export');
108
+
109
+ this._checks.forEach(check => {
110
+ check.onchange = () => this._syncControls();
111
+ });
112
+
113
+ this._deleteStart.onclick = () => {
114
+ this._deleteConfirm.style.display = 'block';
115
+ this._deleteInput.focus();
116
+ };
117
+ this._deleteCancel.onclick = () => this._collapseDelete();
118
+ this._deleteInput.oninput = () => this._syncControls();
119
+ this._deleteSubmit.onclick = () =>
120
+ this.publisher.emit('delete', this._deleteInput.value);
121
+
122
+ model.publisher.on('profile', () => this.render());
123
+ model.publisher.on('error', 'renderError', this);
124
+ model.publisher.on('export', 'download', this);
125
+ model.publisher.on('busy', busy => {
126
+ this._busy = busy;
127
+ this._syncControls();
128
+ });
129
+
130
+ this._renderLegalLinks();
131
+ }
132
+
133
+ // панель вместо #lobby; профиль рисуется заново, чекбоксы и блок
134
+ // удаления сброшены
135
+ show() {
136
+ this._panel.style.display = 'flex';
137
+ this._lobby.style.display = 'none';
138
+
139
+ this._checks.forEach(check => {
140
+ check.checked = false;
141
+ });
142
+ this._collapseDelete();
143
+ this._clearMessages();
144
+ this.render();
145
+ }
146
+
147
+ hide() {
148
+ // закрытой панели закрывать нечего
149
+ if (this._panel.style.display === 'none') {
150
+ return;
151
+ }
152
+
153
+ this._panel.style.display = 'none';
154
+ this._lobby.style.display = 'flex';
155
+ }
156
+
157
+ // true — все три согласия отмечены
158
+ consentGiven() {
159
+ return this._checks.every(check => check.checked);
160
+ }
161
+
162
+ render() {
163
+ const profile = this._model.getProfile();
164
+ const clientConfig = this._model.getClientConfig();
165
+
166
+ if (!profile || !clientConfig) {
167
+ return;
168
+ }
169
+
170
+ const titles = this._config.providerTitles;
171
+ const title = provider => titles[provider] ?? provider;
172
+ const { inactiveDays, inactiveDaysOAuth } = clientConfig.accounts;
173
+ const linked = new Map(profile.providers.map(p => [p.provider, p]));
174
+
175
+ this._nick.textContent = profile.nick;
176
+ this._deleteNick.textContent = profile.nick;
177
+
178
+ this._renderStatus(profile, title);
179
+ this._renderBenefits(inactiveDays, inactiveDaysOAuth);
180
+
181
+ this._providers.replaceChildren();
182
+ this._linkBtns = [];
183
+ this._unlinkBtns = [];
184
+
185
+ if (clientConfig.providers.length === 0) {
186
+ this._providers.textContent = NO_PROVIDERS;
187
+ }
188
+
189
+ clientConfig.providers.forEach(provider => {
190
+ this._providers.append(
191
+ this._renderProviderRow(
192
+ provider,
193
+ title(provider),
194
+ linked.get(provider),
195
+ ),
196
+ );
197
+ });
198
+
199
+ // согласия нужны, пока есть что привязывать
200
+ this._consent.style.display = clientConfig.providers.some(
201
+ provider => !linked.has(provider),
202
+ )
203
+ ? 'block'
204
+ : 'none';
205
+
206
+ this._sessionCard.style.display =
207
+ profile.providers.length > 0 ? 'block' : 'none';
208
+
209
+ const deletable = profile.deletable !== false;
210
+
211
+ this._deleteAdminNote.style.display = deletable ? 'none' : 'block';
212
+ this._deleteStart.style.display = deletable ? '' : 'none';
213
+
214
+ if (!deletable) {
215
+ this._collapseDelete();
216
+ }
217
+
218
+ this._renderLegalLinks();
219
+ this._syncControls();
220
+ }
221
+
222
+ _renderStatus(profile, title) {
223
+ const days = profile.inactiveDays;
224
+
225
+ if (profile.role === 'admin') {
226
+ this._status.textContent =
227
+ 'Admin profile. It is not deleted for inactivity.';
228
+ } else if (profile.providers.length === 0) {
229
+ this._status.textContent = `Not linked. This profile exists only in this browser and is deleted after ${days} days of inactivity.`;
230
+ } else {
231
+ const names = profile.providers.map(p => title(p.provider)).join(', ');
232
+
233
+ this._status.textContent = `Linked: ${names}. The profile is deleted after ${days} days of inactivity.`;
234
+ }
235
+ }
236
+
237
+ _renderBenefits(baseDays, oauthDays) {
238
+ const items = [
239
+ 'Restore your profile in any browser or on any device',
240
+ `The profile is kept for ${oauthDays} days of inactivity instead of ${baseDays}`,
241
+ 'Sign out and sign back in',
242
+ ];
243
+
244
+ this._benefits.replaceChildren(
245
+ ...items.map(text => {
246
+ const li = document.createElement('li');
247
+
248
+ li.textContent = text;
249
+
250
+ return li;
251
+ }),
252
+ );
253
+ }
254
+
255
+ _renderProviderRow(provider, title, link) {
256
+ const row = document.createElement('div');
257
+ const btn = document.createElement('input');
258
+
259
+ btn.type = 'button';
260
+ row.className = 'profile-provider';
261
+
262
+ if (link) {
263
+ const label = document.createElement('span');
264
+
265
+ label.textContent = `${title} — linked on ${new Date(link.linkedAt).toLocaleDateString()}`;
266
+ btn.value = 'Unlink';
267
+ btn.onclick = () => this.publisher.emit('unlink', provider);
268
+ this._unlinkBtns.push(btn);
269
+ row.append(label, btn);
270
+ } else {
271
+ btn.value = `Link ${title}`;
272
+ btn.onclick = () => this.publisher.emit('link', provider);
273
+ this._linkBtns.push(btn);
274
+ row.append(btn);
275
+ }
276
+
277
+ return row;
278
+ }
279
+
280
+ // ссылки на условия и политику: в панели и в футере лобби
281
+ _renderLegalLinks() {
282
+ const legal = this._model.getClientConfig()?.legal;
283
+
284
+ if (!legal) {
285
+ return;
286
+ }
287
+
288
+ const elems = this._elems;
289
+ const set = (nodes, href) => {
290
+ nodes.forEach(node => {
291
+ if (node) {
292
+ node.href = href;
293
+ }
294
+ });
295
+ };
296
+
297
+ set(
298
+ [
299
+ ...this._panel.getElementsByClassName(elems.termsLinkClass),
300
+ document.getElementById(elems.footerTermsId),
301
+ ],
302
+ legal.termsUrl,
303
+ );
304
+ set(
305
+ [
306
+ ...this._panel.getElementsByClassName(elems.privacyLinkClass),
307
+ document.getElementById(elems.footerPrivacyId),
308
+ ],
309
+ legal.privacyUrl,
310
+ );
311
+ }
312
+
313
+ // доступность кнопок от согласий, ввода ника и занятости (запрос идёт)
314
+ _syncControls() {
315
+ const busy = this._busy;
316
+ const nick = this._model.getProfile()?.nick;
317
+ const consent = this.consentGiven();
318
+
319
+ this._linkBtns.forEach(btn => {
320
+ btn.disabled = busy || !consent;
321
+ });
322
+ this._unlinkBtns.forEach(btn => {
323
+ btn.disabled = busy;
324
+ });
325
+ this._signOut.disabled = busy;
326
+ this._exportBtn.disabled = busy;
327
+ this._deleteSubmit.disabled =
328
+ busy || !nick || this._deleteInput.value !== nick;
329
+ }
330
+
331
+ _collapseDelete() {
332
+ this._deleteConfirm.style.display = 'none';
333
+ this._deleteInput.value = '';
334
+ this._syncControls();
335
+ }
336
+
337
+ _clearMessages() {
338
+ this._linkNotice.textContent = '';
339
+
340
+ Object.values(this._errorLines).forEach(line => {
341
+ line.textContent = '';
342
+ });
343
+ }
344
+
345
+ // итог привязки, завершённой при возврате с провайдера
346
+ renderLinkResult({ ok, code }) {
347
+ this._clearMessages();
348
+
349
+ if (ok) {
350
+ this._linkNotice.textContent = LINK_SUCCESS;
351
+ } else {
352
+ this._linkError.textContent = AREA_ERRORS.link(code);
353
+ }
354
+ }
355
+
356
+ renderError({ area, code }) {
357
+ this._clearMessages();
358
+ this._errorLines[area].textContent = AREA_ERRORS[area](code);
359
+ }
360
+
361
+ // выгрузка данных профиля файлом
362
+ download({ data, fileName }) {
363
+ const url = URL.createObjectURL(
364
+ new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
365
+ );
366
+ const link = document.createElement('a');
367
+
368
+ link.href = url;
369
+ link.download = fileName;
370
+ link.click();
371
+ URL.revokeObjectURL(url);
372
+ }
373
+ }