Blame Raw
Cohee · e3f41666 · · 934 lines (30.5 KB)
1 contributor
1import { getRequestHeaders } from '../script.js';
2import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
3import { canViewSecrets } from './secrets.js';
4import { renderTemplateAsync } from './templates.js';
5import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './utils.js';
6
7/**
8 * @type {import('../../src/users.js').UserViewModel} Logged in user
9 */
10export let currentUser = null;
11export let accountsEnabled = false;
12
13// Extend the session every 10 minutes
14const SESSION_EXTEND_INTERVAL = 10 * 60 * 1000;
15
16/**
17 * Enable or disable user account controls in the UI.
18 * @param {boolean} isEnabled User account controls enabled
19 * @returns {Promise<void>}
20 */
21export async function setUserControls(isEnabled) {
22 accountsEnabled = isEnabled;
23
24 if (!isEnabled) {
25 $('#logout_button').hide();
26 $('#admin_button').hide();
27 return;
28 }
29
30 $('#logout_button').show();
31 await getCurrentUser();
32}
33
34/**
35 * Check if the current user is an admin.
36 * @returns {boolean} True if the current user is an admin
37 */
38export function isAdmin() {
39 if (!accountsEnabled) {
40 return true;
41 }
42
43 if (!currentUser) {
44 return false;
45 }
46
47 return Boolean(currentUser.admin);
48}
49
50/**
51 * Gets the handle string of the current user.
52 * @returns {string} User handle
53 */
54export function getCurrentUserHandle() {
55 return currentUser?.handle || 'default-user';
56}
57
58/**
59 * Get the current user.
60 * @returns {Promise<void>}
61 */
62async function getCurrentUser() {
63 try {
64 const response = await fetch('/api/users/me', {
65 headers: getRequestHeaders(),
66 });
67
68 if (!response.ok) {
69 throw new Error('Failed to get current user');
70 }
71
72 currentUser = await response.json();
73 $('#admin_button').toggle(accountsEnabled && isAdmin());
74 } catch (error) {
75 console.error('Error getting current user:', error);
76 }
77}
78
79/**
80 * Get a list of all users.
81 * @returns {Promise<import('../../src/users.js').UserViewModel[]>} Users
82 */
83async function getUsers() {
84 try {
85 const response = await fetch('/api/users/get', {
86 method: 'POST',
87 headers: getRequestHeaders({ omitContentType: true }),
88 });
89
90 if (!response.ok) {
91 throw new Error('Failed to get users');
92 }
93
94 return response.json();
95 } catch (error) {
96 console.error('Error getting users:', error);
97 }
98}
99
100/**
101 * Enable a user account.
102 * @param {string} handle User handle
103 * @param {function} callback Success callback
104 * @returns {Promise<void>}
105 */
106async function enableUser(handle, callback) {
107 try {
108 const response = await fetch('/api/users/enable', {
109 method: 'POST',
110 headers: getRequestHeaders(),
111 body: JSON.stringify({ handle }),
112 });
113
114 if (!response.ok) {
115 const data = await response.json();
116 toastr.error(data.error || 'Unknown error', 'Failed to enable user');
117 throw new Error('Failed to enable user');
118 }
119
120 callback();
121 } catch (error) {
122 console.error('Error enabling user:', error);
123 }
124}
125
126async function disableUser(handle, callback) {
127 try {
128 const response = await fetch('/api/users/disable', {
129 method: 'POST',
130 headers: getRequestHeaders(),
131 body: JSON.stringify({ handle }),
132 });
133
134 if (!response.ok) {
135 const data = await response.json();
136 toastr.error(data?.error || 'Unknown error', 'Failed to disable user');
137 throw new Error('Failed to disable user');
138 }
139
140 callback();
141 } catch (error) {
142 console.error('Error disabling user:', error);
143 }
144}
145
146/**
147 * Promote a user to admin.
148 * @param {string} handle User handle
149 * @param {function} callback Success callback
150 * @returns {Promise<void>}
151 */
152async function promoteUser(handle, callback) {
153 try {
154 const response = await fetch('/api/users/promote', {
155 method: 'POST',
156 headers: getRequestHeaders(),
157 body: JSON.stringify({ handle }),
158 });
159
160 if (!response.ok) {
161 const data = await response.json();
162 toastr.error(data.error || 'Unknown error', 'Failed to promote user');
163 throw new Error('Failed to promote user');
164 }
165
166 callback();
167 } catch (error) {
168 console.error('Error promoting user:', error);
169 }
170}
171
172/**
173 * Demote a user from admin.
174 * @param {string} handle User handle
175 * @param {function} callback Success callback
176 */
177async function demoteUser(handle, callback) {
178 try {
179 const response = await fetch('/api/users/demote', {
180 method: 'POST',
181 headers: getRequestHeaders(),
182 body: JSON.stringify({ handle }),
183 });
184
185 if (!response.ok) {
186 const data = await response.json();
187 toastr.error(data.error || 'Unknown error', 'Failed to demote user');
188 throw new Error('Failed to demote user');
189 }
190
191 callback();
192 } catch (error) {
193 console.error('Error demoting user:', error);
194 }
195}
196
197/**
198 * Create a new user.
199 * @param {HTMLFormElement} form Form element
200 */
201async function createUser(form, callback) {
202 const errors = [];
203 const formData = new FormData(form);
204
205 if (!formData.get('handle')) {
206 errors.push('Handle is required');
207 }
208
209 if (formData.get('password') !== formData.get('confirm')) {
210 errors.push('Passwords do not match');
211 }
212
213 if (errors.length) {
214 toastr.error(errors.join(', '), 'Failed to create user');
215 return;
216 }
217
218 const body = {};
219 formData.forEach(function (value, key) {
220 if (key === 'confirm') {
221 return;
222 }
223 if (key.startsWith('_')) {
224 key = key.substring(1);
225 }
226 body[key] = value;
227 });
228
229 try {
230 const response = await fetch('/api/users/create', {
231 method: 'POST',
232 headers: getRequestHeaders(),
233 body: JSON.stringify(body),
234 });
235
236 if (!response.ok) {
237 const data = await response.json();
238 toastr.error(data.error || 'Unknown error', 'Failed to create user');
239 throw new Error('Failed to create user');
240 }
241
242 form.reset();
243 callback();
244 } catch (error) {
245 console.error('Error creating user:', error);
246 }
247}
248
249/**
250 * Backup a user's data.
251 * @param {string} handle Handle of the user to backup
252 * @param {function} callback Success callback
253 * @returns {Promise<void>}
254 */
255async function backupUserData(handle, callback) {
256 try {
257 toastr.info('Please wait for the download to start.', 'Backup Requested');
258 const response = await fetch('/api/users/backup', {
259 method: 'POST',
260 headers: getRequestHeaders(),
261 body: JSON.stringify({ handle }),
262 });
263
264 if (!response.ok) {
265 const data = await response.json();
266 toastr.error(data.error || 'Unknown error', 'Failed to backup user data');
267 throw new Error('Failed to backup user data');
268 }
269
270 const includesSecrets = await canViewSecrets();
271 if (includesSecrets === false) {
272 toastr.warning('The backup will not include secrets due to a server configuration.', 'Secrets Not Included');
273 }
274
275 const blob = await response.blob();
276 const header = response.headers.get('Content-Disposition');
277 const parts = header.split(';');
278 const filename = parts[1].split('=')[1].replaceAll('"', '');
279 const url = URL.createObjectURL(blob);
280 const a = document.createElement('a');
281 a.href = url;
282 a.download = filename;
283 a.click();
284 URL.revokeObjectURL(url);
285 callback();
286 } catch (error) {
287 console.error('Error backing up user data:', error);
288 }
289}
290
291/**
292 * Shows a popup to change a user's password.
293 * @param {string} handle User handle
294 * @param {function} callback Success callback
295 */
296async function changePassword(handle, callback) {
297 try {
298 const template = $(await renderTemplateAsync('changePassword'));
299 template.find('.currentPasswordBlock').toggle(!isAdmin());
300 let newPassword = '';
301 let confirmPassword = '';
302 let oldPassword = '';
303 template.find('input[name="current"]').on('input', function () {
304 oldPassword = String($(this).val());
305 });
306 template.find('input[name="password"]').on('input', function () {
307 newPassword = String($(this).val());
308 });
309 template.find('input[name="confirm"]').on('input', function () {
310 confirmPassword = String($(this).val());
311 });
312 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false });
313 if (result === POPUP_RESULT.CANCELLED || result === POPUP_RESULT.NEGATIVE) {
314 throw new Error('Change password cancelled');
315 }
316
317 if (newPassword !== confirmPassword) {
318 toastr.error('Passwords do not match', 'Failed to change password');
319 throw new Error('Passwords do not match');
320 }
321
322 const response = await fetch('/api/users/change-password', {
323 method: 'POST',
324 headers: getRequestHeaders(),
325 body: JSON.stringify({ handle, newPassword, oldPassword }),
326 });
327
328 if (!response.ok) {
329 const data = await response.json();
330 toastr.error(data.error || 'Unknown error', 'Failed to change password');
331 throw new Error('Failed to change password');
332 }
333
334 toastr.success('Password changed successfully', 'Password Changed');
335 callback();
336 } catch (error) {
337 console.error('Error changing password:', error);
338 }
339}
340
341/**
342 * Delete a user.
343 * @param {string} handle User handle
344 * @param {function} callback Success callback
345 */
346async function deleteUser(handle, callback) {
347 try {
348 if (handle === currentUser.handle) {
349 toastr.error('Cannot delete yourself', 'Failed to delete user');
350 throw new Error('Cannot delete yourself');
351 }
352
353 let purge = false;
354 let confirmHandle = '';
355
356 const template = $(await renderTemplateAsync('deleteUser'));
357 template.find('#deleteUserName').text(handle);
358 template.find('input[name="deleteUserData"]').on('input', function () {
359 purge = $(this).is(':checked');
360 });
361 template.find('input[name="deleteUserHandle"]').on('input', function () {
362 confirmHandle = String($(this).val());
363 });
364
365 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel', wide: false, large: false });
366
367 if (result !== POPUP_RESULT.AFFIRMATIVE) {
368 throw new Error('Delete user cancelled');
369 }
370
371 if (handle !== confirmHandle) {
372 toastr.error('Handles do not match', 'Failed to delete user');
373 throw new Error('Handles do not match');
374 }
375
376 const response = await fetch('/api/users/delete', {
377 method: 'POST',
378 headers: getRequestHeaders(),
379 body: JSON.stringify({ handle, purge }),
380 });
381
382 if (!response.ok) {
383 const data = await response.json();
384 toastr.error(data.error || 'Unknown error', 'Failed to delete user');
385 throw new Error('Failed to delete user');
386 }
387
388 toastr.success('User deleted successfully', 'User Deleted');
389 callback();
390 } catch (error) {
391 console.error('Error deleting user:', error);
392 }
393}
394
395/**
396 * Reset a user's settings.
397 * @param {string} handle User handle
398 * @param {function} callback Success callback
399 */
400async function resetSettings(handle, callback) {
401 try {
402 let password = '';
403 const template = $(await renderTemplateAsync('resetSettings'));
404 template.find('input[name="password"]').on('input', function () {
405 password = String($(this).val());
406 });
407 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Reset', cancelButton: 'Cancel', wide: false, large: false });
408
409 if (result !== POPUP_RESULT.AFFIRMATIVE) {
410 throw new Error('Reset settings cancelled');
411 }
412
413 const response = await fetch('/api/users/reset-settings', {
414 method: 'POST',
415 headers: getRequestHeaders(),
416 body: JSON.stringify({ handle, password }),
417 });
418
419 if (!response.ok) {
420 const data = await response.json();
421 toastr.error(data.error || 'Unknown error', 'Failed to reset settings');
422 throw new Error('Failed to reset settings');
423 }
424
425 toastr.success('Settings reset successfully', 'Settings Reset');
426 callback();
427 } catch (error) {
428 console.error('Error resetting settings:', error);
429 }
430}
431
432/**
433 * Change a user's display name.
434 * @param {string} handle User handle
435 * @param {string} name Current name
436 * @param {function} callback Success callback
437 */
438async function changeName(handle, name, callback) {
439 try {
440 const template = $(await renderTemplateAsync('changeName'));
441 const result = await callGenericPopup(template, POPUP_TYPE.INPUT, name, { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false });
442
443 if (!result) {
444 throw new Error('Change name cancelled');
445 }
446
447 name = String(result);
448
449 const response = await fetch('/api/users/change-name', {
450 method: 'POST',
451 headers: getRequestHeaders(),
452 body: JSON.stringify({ handle, name }),
453 });
454
455 if (!response.ok) {
456 const data = await response.json();
457 toastr.error(data.error || 'Unknown error', 'Failed to change name');
458 throw new Error('Failed to change name');
459 }
460
461 toastr.success('Name changed successfully', 'Name Changed');
462 callback();
463 } catch (error) {
464 console.error('Error changing name:', error);
465 }
466}
467
468/**
469 * Restore a settings snapshot.
470 * @param {string} name Snapshot name
471 * @param {function} callback Success callback
472 */
473async function restoreSnapshot(name, callback) {
474 try {
475 const confirm = await callGenericPopup(
476 `Are you sure you want to restore the settings from "${name}"?`,
477 POPUP_TYPE.CONFIRM,
478 '',
479 { okButton: 'Restore', cancelButton: 'Cancel', wide: false, large: false },
480 );
481
482 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
483 throw new Error('Restore snapshot cancelled');
484 }
485
486 const response = await fetch('/api/settings/restore-snapshot', {
487 method: 'POST',
488 headers: getRequestHeaders(),
489 body: JSON.stringify({ name }),
490 });
491
492 if (!response.ok) {
493 const data = await response.json();
494 toastr.error(data.error || 'Unknown error', 'Failed to restore snapshot');
495 throw new Error('Failed to restore snapshot');
496 }
497
498 callback();
499 } catch (error) {
500 console.error('Error restoring snapshot:', error);
501 }
502}
503
504/**
505 * Load the content of a settings snapshot.
506 * @param {string} name Snapshot name
507 * @returns {Promise<string>} Snapshot content
508 */
509async function loadSnapshotContent(name) {
510 try {
511 const response = await fetch('/api/settings/load-snapshot', {
512 method: 'POST',
513 headers: getRequestHeaders(),
514 body: JSON.stringify({ name }),
515 });
516
517 if (!response.ok) {
518 const data = await response.json();
519 toastr.error(data.error || 'Unknown error', 'Failed to load snapshot content');
520 throw new Error('Failed to load snapshot content');
521 }
522
523 return response.text();
524 } catch (error) {
525 console.error('Error loading snapshot content:', error);
526 }
527}
528
529/**
530 * Gets a list of settings snapshots.
531 * @returns {Promise<Snapshot[]>} List of snapshots
532 * @typedef {Object} Snapshot
533 * @property {string} name Snapshot name
534 * @property {number} date Date in milliseconds
535 * @property {number} size File size in bytes
536 */
537async function getSnapshots() {
538 try {
539 const response = await fetch('/api/settings/get-snapshots', {
540 method: 'POST',
541 headers: getRequestHeaders({ omitContentType: true }),
542 });
543
544 if (!response.ok) {
545 const data = await response.json();
546 toastr.error(data.error || 'Unknown error', 'Failed to get settings snapshots');
547 throw new Error('Failed to get settings snapshots');
548 }
549
550 const snapshots = await response.json();
551 return snapshots;
552 } catch (error) {
553 console.error('Error getting settings snapshots:', error);
554 return [];
555 }
556}
557
558/**
559 * Make a snapshot of the current settings.
560 * @param {function} callback Success callback
561 * @returns {Promise<void>}
562 */
563async function makeSnapshot(callback) {
564 try {
565 const response = await fetch('/api/settings/make-snapshot', {
566 method: 'POST',
567 headers: getRequestHeaders({ omitContentType: true }),
568 });
569
570 if (!response.ok) {
571 const data = await response.json();
572 toastr.error(data.error || 'Unknown error', 'Failed to make snapshot');
573 throw new Error('Failed to make snapshot');
574 }
575
576 toastr.success('Snapshot created successfully', 'Snapshot Created');
577 callback();
578 } catch (error) {
579 console.error('Error making snapshot:', error);
580 }
581}
582
583/**
584 * Open the settings snapshots view.
585 */
586async function viewSettingsSnapshots() {
587 const template = $(await renderTemplateAsync('snapshotsView'));
588 async function renderSnapshots() {
589 const snapshots = await getSnapshots();
590 template.find('.snapshotList').empty();
591
592 for (const snapshot of snapshots.sort((a, b) => b.date - a.date)) {
593 const snapshotBlock = template.find('.snapshotTemplate .snapshot').clone();
594 snapshotBlock.find('.snapshotName').text(snapshot.name);
595 snapshotBlock.find('.snapshotDate').text(new Date(snapshot.date).toLocaleString());
596 snapshotBlock.find('.snapshotSize').text(humanFileSize(snapshot.size));
597 snapshotBlock.find('.snapshotRestoreButton').on('click', async (e) => {
598 e.stopPropagation();
599 restoreSnapshot(snapshot.name, () => location.reload());
600 });
601 snapshotBlock.find('.inline-drawer-toggle').on('click', async () => {
602 const contentBlock = snapshotBlock.find('.snapshotContent');
603 if (!contentBlock.val()) {
604 const content = await loadSnapshotContent(snapshot.name);
605 contentBlock.val(content);
606 }
607 });
608 template.find('.snapshotList').append(snapshotBlock);
609 }
610 }
611
612 callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false, allowVerticalScrolling: true });
613 template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots));
614 renderSnapshots();
615}
616
617/**
618 * Reset everything to default.
619 * @param {function} callback Success callback
620 */
621async function resetEverything(callback) {
622 try {
623 const step1Response = await fetch('/api/users/reset-step1', {
624 method: 'POST',
625 headers: getRequestHeaders({ omitContentType: true }),
626 });
627
628 if (!step1Response.ok) {
629 const data = await step1Response.json();
630 toastr.error(data.error || 'Unknown error', 'Failed to reset');
631 throw new Error('Failed to reset everything');
632 }
633
634 let password = '';
635 let code = '';
636
637 const template = $(await renderTemplateAsync('userReset'));
638 template.find('input[name="password"]').on('input', function () {
639 password = String($(this).val());
640 });
641 template.find('input[name="code"]').on('input', function () {
642 code = String($(this).val());
643 });
644 const confirm = await callGenericPopup(
645 template,
646 POPUP_TYPE.CONFIRM,
647 '',
648 { okButton: 'Reset', cancelButton: 'Cancel', wide: false, large: false },
649 );
650
651 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
652 throw new Error('Reset everything cancelled');
653 }
654
655 const step2Response = await fetch('/api/users/reset-step2', {
656 method: 'POST',
657 headers: getRequestHeaders(),
658 body: JSON.stringify({ password, code }),
659 });
660
661 if (!step2Response.ok) {
662 const data = await step2Response.json();
663 toastr.error(data.error || 'Unknown error', 'Failed to reset');
664 throw new Error('Failed to reset everything');
665 }
666
667 toastr.success('Everything reset successfully', 'Reset Everything');
668 callback();
669 } catch (error) {
670 console.error('Error resetting everything:', error);
671 }
672}
673
674async function openUserProfile() {
675 await getCurrentUser();
676 const template = $(await renderTemplateAsync('userProfile'));
677 template.find('.userName').text(currentUser.name);
678 template.find('.userHandle').text(currentUser.handle);
679 template.find('.avatar img').attr('src', currentUser.avatar);
680 template.find('.userRole').text(currentUser.admin ? 'Admin' : 'User');
681 template.find('.userCreated').text(new Date(currentUser.created).toLocaleString());
682 template.find('.hasPassword').toggle(currentUser.password);
683 template.find('.noPassword').toggle(!currentUser.password);
684 template.find('.userSettingsSnapshotsButton').on('click', () => viewSettingsSnapshots());
685 template.find('.userChangeNameButton').on('click', async () => changeName(currentUser.handle, currentUser.name, async () => {
686 await getCurrentUser();
687 template.find('.userName').text(currentUser.name);
688 }));
689 template.find('.userChangePasswordButton').on('click', () => changePassword(currentUser.handle, async () => {
690 await getCurrentUser();
691 template.find('.hasPassword').toggle(currentUser.password);
692 template.find('.noPassword').toggle(!currentUser.password);
693 }));
694 template.find('.userBackupButton').on('click', function () {
695 $(this).addClass('disabled');
696 backupUserData(currentUser.handle, () => {
697 $(this).removeClass('disabled');
698 });
699 });
700 template.find('.userResetSettingsButton').on('click', () => resetSettings(currentUser.handle, () => location.reload()));
701 template.find('.userResetAllButton').on('click', () => resetEverything(() => location.reload()));
702 template.find('.userAvatarChange').on('click', () => template.find('.avatarUpload').trigger('click'));
703 template.find('.avatarUpload').on('change', async function () {
704 if (!(this instanceof HTMLInputElement)) {
705 return;
706 }
707
708 const file = this.files[0];
709 if (!file) {
710 return;
711 }
712
713 await cropAndUploadAvatar(currentUser.handle, file);
714 await getCurrentUser();
715 template.find('.avatar img').attr('src', currentUser.avatar);
716 });
717 template.find('.userAvatarRemove').on('click', async function () {
718 await changeAvatar(currentUser.handle, '');
719 await getCurrentUser();
720 template.find('.avatar img').attr('src', currentUser.avatar);
721 });
722
723 if (!accountsEnabled) {
724 template.find('[data-require-accounts]').hide();
725 template.find('.accountsDisabledHint').show();
726 }
727
728 const popupOptions = {
729 okButton: 'Close',
730 wide: false,
731 large: false,
732 allowVerticalScrolling: true,
733 allowHorizontalScrolling: false,
734 };
735 callGenericPopup(template, POPUP_TYPE.TEXT, '', popupOptions);
736}
737
738/**
739 * Crop and upload an avatar image.
740 * @param {string} handle User handle
741 * @param {File} file Avatar file
742 * @returns {Promise<string>}
743 */
744async function cropAndUploadAvatar(handle, file) {
745 const dataUrl = await getBase64Async(await ensureImageFormatSupported(file));
746 const croppedImage = await callGenericPopup('Set the crop position of the avatar image', POPUP_TYPE.CROP, '', { cropAspect: 1, cropImage: dataUrl });
747 if (!croppedImage) {
748 return;
749 }
750
751 await changeAvatar(handle, String(croppedImage));
752
753 return String(croppedImage);
754}
755
756/**
757 * Change the avatar of the user.
758 * @param {string} handle User handle
759 * @param {string} avatar File to upload or base64 string
760 * @returns {Promise<void>} Avatar URL
761 */
762async function changeAvatar(handle, avatar) {
763 try {
764 const response = await fetch('/api/users/change-avatar', {
765 method: 'POST',
766 headers: getRequestHeaders(),
767 body: JSON.stringify({ avatar, handle }),
768 });
769
770 if (!response.ok) {
771 const data = await response.json();
772 toastr.error(data.error || 'Unknown error', 'Failed to change avatar');
773 return;
774 }
775 } catch (error) {
776 console.error('Error changing avatar:', error);
777 }
778}
779
780async function openAdminPanel() {
781 async function renderUsers() {
782 const users = await getUsers();
783 template.find('.usersList').empty();
784 for (const user of users) {
785 const userBlock = template.find('.userAccountTemplate .userAccount').clone();
786 userBlock.find('.userName').text(user.name);
787 userBlock.find('.userHandle').text(user.handle);
788 userBlock.find('.userStatus').text(user.enabled ? 'Enabled' : 'Disabled');
789 userBlock.find('.userRole').text(user.admin ? 'Admin' : 'User');
790 userBlock.find('.avatar img').attr('src', user.avatar);
791 userBlock.find('.hasPassword').toggle(user.password);
792 userBlock.find('.noPassword').toggle(!user.password);
793 userBlock.find('.userCreated').text(new Date(user.created).toLocaleString());
794 userBlock.find('.userEnableButton').toggle(!user.enabled).on('click', () => enableUser(user.handle, renderUsers));
795 userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers));
796 userBlock.find('.userPromoteButton').toggle(!user.admin).on('click', () => promoteUser(user.handle, renderUsers));
797 userBlock.find('.userDemoteButton').toggle(user.admin).on('click', () => demoteUser(user.handle, renderUsers));
798 userBlock.find('.userChangePasswordButton').on('click', () => changePassword(user.handle, renderUsers));
799 userBlock.find('.userDelete').on('click', () => deleteUser(user.handle, renderUsers));
800 userBlock.find('.userChangeNameButton').on('click', async () => changeName(user.handle, user.name, renderUsers));
801 userBlock.find('.userBackupButton').on('click', function () {
802 $(this).addClass('disabled').off('click');
803 backupUserData(user.handle, renderUsers);
804 });
805 userBlock.find('.userAvatarChange').on('click', () => userBlock.find('.avatarUpload').trigger('click'));
806 userBlock.find('.avatarUpload').on('change', async function () {
807 if (!(this instanceof HTMLInputElement)) {
808 return;
809 }
810
811 const file = this.files[0];
812 if (!file) {
813 return;
814 }
815
816 await cropAndUploadAvatar(user.handle, file);
817 renderUsers();
818 });
819 userBlock.find('.userAvatarRemove').on('click', async function () {
820 await changeAvatar(user.handle, '');
821 renderUsers();
822 });
823 template.find('.usersList').append(userBlock);
824 }
825 }
826
827 const template = $(await renderTemplateAsync('admin'));
828
829 template.find('.adminNav > button').on('click', function () {
830 const target = String($(this).data('target-tab'));
831 template.find('.navTab').each(function () {
832 $(this).toggle(this.classList.contains(target));
833 });
834 });
835
836 template.find('.createUserDisplayName').on('input', async function () {
837 const slug = await slugify(String($(this).val()));
838 template.find('.createUserHandle').val(slug);
839 });
840
841 template.find('.userCreateForm').on('submit', function (event) {
842 if (!(event.target instanceof HTMLFormElement)) {
843 return;
844 }
845
846 event.preventDefault();
847 createUser(event.target, () => {
848 template.find('.manageUsersButton').trigger('click');
849 renderUsers();
850 });
851 });
852
853 callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false, allowVerticalScrolling: true, allowHorizontalScrolling: false });
854 renderUsers();
855}
856
857/**
858 * Log out the current user.
859 * @returns {Promise<void>}
860 */
861async function logout() {
862 await fetch('/api/users/logout', {
863 method: 'POST',
864 headers: getRequestHeaders({ omitContentType: true }),
865 });
866
867 // On an explicit logout stop auto login
868 // to allow user to change username even
869 // when auto auth (such as authelia or basic)
870 // would be valid
871 const urlParams = new URLSearchParams(window.location.search);
872 urlParams.set('noauto', 'true');
873
874 window.location.search = urlParams.toString();
875}
876
877/**
878 * Runs a text through the slugify API endpoint.
879 * @param {string} text Text to slugify
880 * @returns {Promise<string>} Slugified text
881 */
882async function slugify(text) {
883 try {
884 const response = await fetch('/api/users/slugify', {
885 method: 'POST',
886 headers: getRequestHeaders(),
887 body: JSON.stringify({ text }),
888 });
889
890 if (!response.ok) {
891 throw new Error('Failed to slugify text');
892 }
893
894 return response.text();
895 } catch (error) {
896 console.error('Error slugifying text:', error);
897 return text;
898 }
899}
900
901/**
902 * Pings the server to extend the user session.
903 */
904async function extendUserSession() {
905 try {
906 const response = await fetch('/api/ping?extend=1', {
907 method: 'POST',
908 headers: getRequestHeaders({ omitContentType: true }),
909 });
910
911 if (!response.ok) {
912 throw new Error('Ping did not succeed', { cause: response.status });
913 }
914 } catch (error) {
915 console.error('Failed to extend user session', error);
916 }
917}
918
919jQuery(() => {
920 $('#logout_button').on('click', () => {
921 logout();
922 });
923 $('#admin_button').on('click', () => {
924 openAdminPanel();
925 });
926 $('#account_button').on('click', () => {
927 openUserProfile();
928 });
929 setInterval(async () => {
930 if (currentUser) {
931 await extendUserSession();
932 }
933 }, SESSION_EXTEND_INTERVAL);
934});