Admin change password flow

This commit is contained in:
Cohee 2024-04-10 00:01:03 +03:00
parent 31cc6e51b5
commit 189d096834
8 changed files with 159 additions and 40 deletions

5
public/css/accounts.css Normal file
View File

@ -0,0 +1,5 @@
.userAccount {
border: 1px solid var(--SmartThemeBorderColor);
padding: 5px 10px;
border-radius: 5px;
}

View File

@ -219,7 +219,7 @@ export function callGenericPopup(text, type, inputValue = '', { okButton, cancel
text, text,
type, type,
inputValue, inputValue,
{ okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling }, { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cancelButton },
); );
return popup.show(); return popup.show();
} }

View File

@ -1,10 +1,10 @@
<div class="adminTabs wide100p"> <div class="adminTabs wide100p">
<nav class="adminNav flex-container alignItemsCenter justifyCenter"> <nav class="adminNav flex-container alignItemsCenter justifyCenter">
<button type="button" class="manageUsersButton menu_button menu_button_icon" data-target-tab="usersList"> <button type="button" class="manageUsersButton menu_button menu_button_icon" data-target-tab="usersList">
<h4>Manage Users</h4> <h4 data-i18n="Manager Users">Manage Users</h4>
</button> </button>
<button type="button" class="newUserButton menu_button menu_button_icon" data-target-tab="registerNewUserBlock"> <button type="button" class="newUserButton menu_button menu_button_icon" data-target-tab="registerNewUserBlock">
<h4>New User</h4> <h4 data-i18n="New User">New User</h4>
</button> </button>
</nav> </nav>
<div class="userAccountTemplate template_element"> <div class="userAccountTemplate template_element">
@ -19,23 +19,24 @@
<i class="hasPassword fa-solid fa-lock"></i> <i class="hasPassword fa-solid fa-lock"></i>
<i class="noPassword fa-solid fa-lock-open"></i> <i class="noPassword fa-solid fa-lock-open"></i>
<h3 class="userName margin0"></h3> <h3 class="userName margin0"></h3>
<small class="userHandle">@userhandle</small> <small class="userHandle">&nbsp;</small>
</div> </div>
<div class="flex-container flexFlowColumn flexNoGap"> <div class="flex-container flexFlowColumn flexNoGap">
<span> <span>
<span>Role:</span> <span data-i18n="Role:">Role:</span>
<span class="userRole"></span> <span class="userRole"></span>
</span> </span>
<span> <span>
<span>Status:</span> <span data-i18n="Status:">Status:</span>
<span class="userStatus">Status</span> <span class="userStatus">&nbsp;</span>
</span> </span>
<span> <span>
<span>Created:</span> <span data-i18n="Created:">Created:</span>
<span class="userCreated">Date</span> <span class="userCreated">&nbsp;</span>
</span> </span>
</div> </div>
</div> </div>
<div class="flex-container flexFlowColumn">
<div class="flex-container"> <div class="flex-container">
<div class="userEditButton menu_button disabled"> <div class="userEditButton menu_button disabled">
<i class="fa-fw fa-solid fa-pencil"></i> <i class="fa-fw fa-solid fa-pencil"></i>
@ -52,9 +53,18 @@
<div class="userDemoteButton menu_button" title="Demote user to regular user."> <div class="userDemoteButton menu_button" title="Demote user to regular user.">
<i class="fa-fw fa-solid fa-arrow-down"></i> <i class="fa-fw fa-solid fa-arrow-down"></i>
</div> </div>
</div>
<div class="flex-container">
<div class="userBackupButton menu_button menu_button_icon" title="Download a backup of user data."> <div class="userBackupButton menu_button menu_button_icon" title="Download a backup of user data.">
<i class="fa-fw fa-solid fa-download"></i> <i class="fa-fw fa-solid fa-download"></i>
</div> </div>
<div class="userChangePasswordButton menu_button" title="Change user password.">
<i class="fa-fw fa-solid fa-key"></i>
</div>
<div class="userDelete menu_button warning" title="Delete user account.">
<i class="fa-fw fa-solid fa-trash"></i>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -62,30 +72,33 @@
</div> </div>
<div class="navTab registerNewUserBlock" style="display: none;"> <div class="navTab registerNewUserBlock" style="display: none;">
<form class="flex-container flexFlowColumn flexGap10 userCreateForm" action="javascript:void(0);"> <form class="flex-container flexFlowColumn flexGap10 userCreateForm" action="javascript:void(0);">
<h3> <h3 data-i18n="Register New SillyTavern User">
Register New SillyTavern User Register New SillyTavern User
</h3> </h3>
<div class="flex-container flexNoGap"> <div class="flex-container flexNoGap">
<span>User Handle:</span> <span data-i18n="User Handle:">User Handle:</span>
<input name="handle" class="text_pole" placeholder="Lowercase letters, numbers, and dashes only." type="text" pattern="[a-z0-9-]+"> <input name="handle" class="text_pole" placeholder="Lowercase letters, numbers, and dashes only." type="text" pattern="[a-z0-9-]+">
</div> </div>
<div class="flex-container flexNoGap"> <div class="flex-container flexNoGap">
<span>Display Name:</span> <span data-i18n="Display Name:">Display Name:</span>
<input name="_name" class="text_pole" type="text" placeholder="Anonymous" autocomplete="username"> <input name="_name" class="text_pole" type="text" placeholder="Anonymous" autocomplete="username">
</div> </div>
<div class="flex-container flexNoGap"> <div class="flex-container flexNoGap">
<span>Password:</span> <span data-i18n="Password:">Password:</span>
<input name="password" class="text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password"> <input name="password" class="text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password">
</div> </div>
<div class="flex-container flexNoGap"> <div class="flex-container flexNoGap">
<span>Confirm Password:</span> <span data-i18n="Confirm Password:">Confirm Password:</span>
<input name="confirm" class="text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password"> <input name="confirm" class="text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password">
</div> </div>
<span> <span data-i18n="This will create a new subfolder...">
This will create a new subfolder in the /data/ directory with the user's handle as the folder name. This will create a new subfolder in the /data/ directory with the user's handle as the folder name.
</span> </span>
<div class="flex-container justifyCenter"> <div class="flex-container justifyCenter">
<button type="submit" class="menu_button newUserRegisterFinalizeButton">Create</div> <button type="submit" class="menu_button menu_button_icon newUserRegisterFinalizeButton">
<i class="fa-fw fa-solid fa-user-plus"></i>
<span data-i18n="Create">Create</span>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@ -0,0 +1,14 @@
<form action="javascript:void(0);" class="flex-container flexFlowColumn">
<div class="currentPasswordBlock">
<label data-i18n="Current Password:" for="user">Current Password:</label>
<input type="text" name="current" class="text_pole" placeholder="[ No password ]" autocomplete="current-password">
</div>
<div class="newPasswordBlock">
<label data-i18n="New Password:" for="password">New Password:</label>
<input type="password" name="password" class="text_pole" placeholder="[ No password ]" autocomplete="new-password">
</div>
<div class="confirmPasswordBlock">
<label data-i18n="Confirm New Password:" for="confirm">Confirm New Password:</label>
<input type="password" name="confirm" class="text_pole" placeholder="[ No password ]" autocomplete="new-password">
</div>
</form>

View File

@ -1,4 +1,5 @@
import { callPopup, getRequestHeaders, renderTemplate } from '../script.js'; import { getRequestHeaders, renderTemplate } from '../script.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
/** /**
* @type {import('../../src/users.js').User} Logged in user * @type {import('../../src/users.js').User} Logged in user
@ -256,6 +257,57 @@ async function backupUserData(handle, callback) {
} }
} }
/**
* Shows a popup to change a user's password.
* @param {string} handle User handle
* @param {function} callback Success callback
*/
async function changePassword(handle, callback) {
try {
const template = $(renderTemplate('changePassword'));
template.find('.currentPasswordBlock').toggle(!isAdmin());
let newPassword = '';
let confirmPassword = '';
let oldPassword = '';
template.find('input[name="current"]').on('input', function () {
oldPassword = String($(this).val());
});
template.find('input[name="password"]').on('input', function () {
newPassword = String($(this).val());
});
template.find('input[name="confirm"]').on('input', function () {
confirmPassword = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Change', cancelButton: 'Cancel', wide: false, large: false });
if (result === POPUP_RESULT.CANCELLED || result === POPUP_RESULT.NEGATIVE) {
throw new Error('Change password cancelled');
}
if (newPassword !== confirmPassword) {
toastr.error('Passwords do not match', 'Failed to change password');
throw new Error('Passwords do not match');
}
const response = await fetch('/api/users/change-password', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ handle, newPassword, oldPassword }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to change password');
throw new Error('Failed to change password');
}
toastr.success('Password changed successfully', 'Password Changed');
callback();
}
catch (error) {
console.error('Error changing password:', error);
}
}
async function openAdminPanel() { async function openAdminPanel() {
async function renderUsers() { async function renderUsers() {
const users = await getUsers(); const users = await getUsers();
@ -274,6 +326,7 @@ async function openAdminPanel() {
userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers)); userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers));
userBlock.find('.userPromoteButton').toggle(!user.admin).on('click', () => promoteUser(user.handle, renderUsers)); userBlock.find('.userPromoteButton').toggle(!user.admin).on('click', () => promoteUser(user.handle, renderUsers));
userBlock.find('.userDemoteButton').toggle(user.admin).on('click', () => demoteUser(user.handle, renderUsers)); userBlock.find('.userDemoteButton').toggle(user.admin).on('click', () => demoteUser(user.handle, renderUsers));
userBlock.find('.userChangePasswordButton').on('click', () => changePassword(user.handle, renderUsers));
userBlock.find('.userBackupButton').on('click', function () { userBlock.find('.userBackupButton').on('click', function () {
$(this).addClass('disabled').off('click'); $(this).addClass('disabled').off('click');
backupUserData(user.handle, renderUsers); backupUserData(user.handle, renderUsers);
@ -303,7 +356,7 @@ async function openAdminPanel() {
}); });
}); });
callPopup(template, 'text', '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false }); callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false });
renderUsers(); renderUsers();
} }

View File

@ -5,6 +5,7 @@
@import url(css/character-group-overlay.css); @import url(css/character-group-overlay.css);
@import url(css/file-form.css); @import url(css/file-form.css);
@import url(css/logprobs.css); @import url(css/logprobs.css);
@import url(css/accounts.css);
:root { :root {
--doc-height: 100%; --doc-height: 100%;

View File

@ -1,3 +1,4 @@
const fsPromises = require('fs').promises;
const storage = require('node-persist'); const storage = require('node-persist');
const express = require('express'); const express = require('express');
const slugify = require('slugify').default; const slugify = require('slugify').default;
@ -191,6 +192,33 @@ router.post('/create', requireAdminMiddleware, jsonParser, async (request, respo
} }
}); });
router.post('/delete', requireAdminMiddleware, jsonParser, async (request, response) => {
try {
if (!request.body.handle) {
console.log('Delete user failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
}
if (request.body.handle === request.user.profile.handle) {
console.log('Delete user failed: Cannot delete yourself');
return response.status(400).json({ error: 'Cannot delete yourself' });
}
await storage.removeItem(toKey(request.body.handle));
if (request.body.purge) {
const directories = getUserDirectories(request.body.handle);
console.log('Deleting data directories for', request.body.handle);
await fsPromises.rm(directories.root, { recursive: true, force: true });
}
return response.sendStatus(204);
} catch (error) {
console.error('User delete failed:', error);
return response.sendStatus(500);
}
});
module.exports = { module.exports = {
router, router,
}; };

View File

@ -67,15 +67,20 @@ router.post('/change-password', jsonParser, async (request, response) => {
return response.status(403).json({ error: 'User is disabled' }); return response.status(403).json({ error: 'User is disabled' });
} }
const isAdminChange = request.user.profile.admin && request.body.handle !== request.user.profile.handle; if (!request.user.profile.admin && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
if (!isAdminChange && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
console.log('Change password failed: Incorrect password'); console.log('Change password failed: Incorrect password');
return response.status(401).json({ error: 'Incorrect password' }); return response.status(401).json({ error: 'Incorrect password' });
} }
if (request.body.newPassword) {
const salt = getPasswordSalt(); const salt = getPasswordSalt();
user.password = getPasswordHash(request.body.newPassword, salt); user.password = getPasswordHash(request.body.newPassword, salt);
user.salt = salt; user.salt = salt;
} else {
user.password = '';
user.salt = '';
}
await storage.setItem(toKey(request.body.handle), user); await storage.setItem(toKey(request.body.handle), user);
return response.sendStatus(204); return response.sendStatus(204);
} catch (error) { } catch (error) {