diff --git a/default/config.yaml b/default/config.yaml index 3e914009a..8b70f73be 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -21,7 +21,7 @@ basicAuthUser: # Enables CORS proxy middleware enableCorsProxy: false # Enable multi-user mode -enableUserAccounts: true +enableUserAccounts: false # Used to sign session cookies. Will be auto-generated if not set cookieSecret: '' # Disable security checks - NOT RECOMMENDED diff --git a/public/index.html b/public/index.html index 352885649..944235149 100644 --- a/public/index.html +++ b/public/index.html @@ -88,6 +88,7 @@ + SillyTavern @@ -3452,7 +3453,21 @@
- +
+ + + +
+
@@ -5277,17 +5292,16 @@ Enable simple UI mode +

+ Your Persona +

- - Before you get started, you must select a user name. + + Before you get started, you must select a persona name. This can be changed at any time via the icon.
-

UI Language:

- -

User Name:

+

Persona Name:

diff --git a/public/script.js b/public/script.js index e81adefef..dd697b331 100644 --- a/public/script.js +++ b/public/script.js @@ -211,6 +211,7 @@ import { loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermati import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js'; import { initPresetManager } from './scripts/preset-manager.js'; import { evaluateMacros } from './scripts/macros.js'; +import { currentUser, setUserControls } from './scripts/user.js'; //exporting functions and vars for mods export { @@ -6097,7 +6098,7 @@ async function doOnboarding(avatarId) { template.find('input[name="enable_simple_mode"]').on('input', function () { simpleUiMode = $(this).is(':checked'); }); - var userName = await callPopup(template, 'input', name1); + let userName = await callPopup(template, 'input', currentUser?.name || name1); if (userName) { userName = userName.replace('\n', ' '); @@ -6151,6 +6152,8 @@ async function getSettings() { $('#your_name').val(name1); } + await setUserControls(data.enable_accounts); + // Allow subscribers to mutate settings eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings); diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html new file mode 100644 index 000000000..4db762b76 --- /dev/null +++ b/public/scripts/templates/admin.html @@ -0,0 +1,77 @@ +
+ +
+
+
+
+ avatar +
+
+
+
+ + +

+ @userhandle +
+
+ + Role: + + + + Status: + Status + + + Created: + Date + +
+
+
+ + + +
+
+
+ + + +
+
diff --git a/public/scripts/user.js b/public/scripts/user.js new file mode 100644 index 000000000..b51a463c3 --- /dev/null +++ b/public/scripts/user.js @@ -0,0 +1,236 @@ +import { callPopup, getRequestHeaders, renderTemplate } from '../script.js'; + +/** + * @type {import('../../src/users.js').User} Logged in user + */ +export let currentUser = null; + +/** + * Enable or disable user account controls in the UI. + * @param {boolean} isEnabled User account controls enabled + * @returns {Promise} + */ +export async function setUserControls(isEnabled) { + if (!isEnabled) { + $('#account_controls').hide(); + return; + } + + $('#account_controls').show(); + await getCurrentUser(); +} + +/** + * Check if the current user is an admin. + * @returns {boolean} True if the current user is an admin + */ +function isAdmin() { + if (!currentUser) { + return false; + } + + return Boolean(currentUser.admin); +} + +/** + * Get the current user. + * @returns {Promise} + */ +async function getCurrentUser() { + try { + const response = await fetch('/api/users/me', { + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error('Failed to get current user'); + } + + currentUser = await response.json(); + $('#admin_button').toggle(isAdmin()); + } catch (error) { + console.error('Error getting current user:', error); + } +} + +async function getUsers() { + try { + const response = await fetch('/api/users/get', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error('Failed to get users'); + } + + return response.json(); + } catch (error) { + console.error('Error getting users:', error); + } +} + +/** + * Enable a user account. + * @param {string} handle User handle + * @param {function} callback Success callback + * @returns {Promise} + */ +async function enableUser(handle, callback) { + try { + const response = await fetch('/api/users/enable', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to enable user'); + throw new Error('Failed to enable user'); + } + + callback(); + } catch (error) { + console.error('Error enabling user:', error); + } +} + +async function disableUser(handle, callback) { + try { + const response = await fetch('/api/users/disable', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ handle }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data?.error || 'Unknown error', 'Failed to disable user'); + throw new Error('Failed to disable user'); + } + + callback(); + } catch (error) { + console.error('Error disabling user:', error); + } +} + +/** + * Create a new user. + * @param {HTMLFormElement} form Form element + */ +async function createUser(form, callback) { + const errors = []; + const formData = new FormData(form); + + if (!formData.get('handle')) { + errors.push('Handle is required'); + } + + if (formData.get('password') !== formData.get('confirm')) { + errors.push('Passwords do not match'); + } + + if (errors.length) { + toastr.error(errors.join(', '), 'Failed to create user'); + return; + } + + const body = {}; + formData.forEach(function (value, key) { + if (key === 'confirm') { + return; + } + if (key.startsWith('_')) { + key = key.substring(1); + } + body[key] = value; + }); + + try { + const response = await fetch('/api/users/create', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to create user'); + throw new Error('Failed to create user'); + } + + form.reset(); + callback(); + } catch (error) { + console.error('Error creating user:', error); + } +} + +async function openAdminPanel() { + async function renderUsers() { + const users = await getUsers(); + template.find('.usersList').empty(); + for (const user of users) { + const userBlock = template.find('.userAccountTemplate .userAccount').clone(); + userBlock.find('.userName').text(user.name); + userBlock.find('.userHandle').text(user.handle); + userBlock.find('.userStatus').text(user.enabled ? 'Enabled' : 'Disabled'); + userBlock.find('.userRole').text(user.admin ? 'Admin' : 'User'); + userBlock.find('.avatar img').attr('src', user.avatar); + userBlock.find('.hasPassword').toggle(user.password); + userBlock.find('.noPassword').toggle(!user.password); + userBlock.find('.userCreated').text(new Date(user.created).toLocaleString()); + userBlock.find('.userEnableButton').toggle(!user.enabled).on('click', () => enableUser(user.handle, renderUsers)); + userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers)); + template.find('.usersList').append(userBlock); + } + } + + const template = $(renderTemplate('admin')); + + template.find('.adminNav > button').on('click', function () { + const target = String($(this).data('target-tab')); + template.find('.navTab').each(function () { + $(this).toggle(this.classList.contains(target)); + }); + }); + + template.find('.userCreateForm').on('submit', function (event) { + if (!(event.target instanceof HTMLFormElement)) { + return; + } + + event.preventDefault(); + createUser(event.target, () => { + template.find('.manageUsersButton').trigger('click'); + renderUsers(); + }); + }); + + callPopup(template, 'text', '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false }); + renderUsers(); +} + +/** + * Log out the current user. + * @returns {Promise} + */ +async function logout() { + await fetch('/api/users/logout', { + method: 'POST', + headers: getRequestHeaders(), + }); + + window.location.reload(); +} + +jQuery(() => { + $('#logout_button').on('click', () => { + logout(); + }); + $('#admin_button').on('click', () => { + openAdminPanel(); + }); +}); diff --git a/public/scripts/userManagement.js b/public/scripts/userManagement.js deleted file mode 100644 index e3cd9d34f..000000000 --- a/public/scripts/userManagement.js +++ /dev/null @@ -1,161 +0,0 @@ -async function registerNewUser() { - let handle = String($("#newUserHandle").val()); - let name = String($("#newUserName").val()); - let password = String($("#newUserPassword").val()); - let passwordConfirm = String($("#newUserPasswordConfirm").val()); - - if (handle.length < 4) { - alert('Username must be at least 4 characters long'); - return; - } - - if (password.length < 8) { - alert('Password must be at least 8 characters long'); - return; - } - - if (password !== passwordConfirm) { - alert("Passwords don't match!") - return - } - - const newUser = { - handle: handle, - name: name || 'Anonymous', - password: password, - enabled: true - }; - - try { - const response = await $.ajax({ - url: '/api/users/create', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify(newUser), - }); - - console.log(response); - if (response.handle) { - console.log('saw user created successfully') - alert('New user created!') - $("#userSelectBlock").empty() - populateUserList() - $("#userListBlock").show() - $("#registerNewUserBlock").hide() - $("#registerNewUserBlock input").val('') - - } - } catch (error) { - console.error('Error creating new user:', error); - alert(error.responseText) - } -} - -async function loginUser() { - const password = $("#userPassword").val(); - const handle = $('.userSelect.selected').data('foruser'); - const userInfo = { - handle: handle, - password: password, - }; - - try { - const response = await $.ajax({ - url: '/api/users/login', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify(userInfo), - }); - - console.log(response); - if (response.handle) { - console.log('successfully logged in'); - alert(`logged in as ${handle}!`); - $('#loader').animate({ opacity: 0 }, 300, function () { - // Insert user handle/password verification code here - // .finally: - $('#loader').remove(); - }); - } - } catch (error) { - console.error('Error logging in:', error); - alert(error.responseText); - } -} - -export async function populateUserList() { - const userList = await getUserList(); - - const registerNewUserButtonHTML = `` - - const newUserRegisterationHTML = ` -
- Register New SillyTavern User -
Username:
-
Display Name:
-
Password:
-
Password confirm:
- This will create a new subfolder in the /data/ directory. -
- - -
-
- ` - - const userSelectHTML = ` - - - - `; - - // Add login screen - $('#loader').append(userSelectHTML); - - const parentDiv = $('#userList'); - - userList.forEach(user => { - const userDiv = $('
') - .attr('id', `userSelect-${user.handle}`) - .attr('data-foruser', user.name) - .addClass('userSelect menu_button flex-container flexFlowCol'); - - const avatarImg = $('') - .addClass('avatar') - .attr('src', user.avatar); - - userDiv.append(avatarImg); - - const userName = $('').text(user.name); - userDiv.append(userName); - - parentDiv.append(userDiv); - }); - - parentDiv.append(registerNewUserButtonHTML) - - $(".userSelect").off('click').on("click", function () { - let selectedUserName = $(this).data('foruser') - $('.userSelect').removeClass('avatar-container selected') - $(this).addClass('avatar-container selected') - console.log(selectedUserName) - $("#passwordHeaderText").text(`Enter password for ${selectedUserName}`) - $("#passwordEntryBlock").show() - }); - - $("#registerNewUserButton").off('click').on('click', function () { - $("#userListBlock").hide() - $("#registerNewUserBlock").show() - }) - - $("#newUserRegisterFinalizeButton").off('click').on('click', registerNewUser) - - $("#newUserRegisterCancelButton").off('click').on('click', function () { - $("#userListBlock").show() - $("#registerNewUserBlock").hide() - }) - - $("#loginButton").off('click').on('click', loginUser) -} diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index ce5baf6cc..117b51f90 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -8,7 +8,8 @@ const { jsonParser } = require('../express-common'); const { getAllUserHandles, getUserDirectories } = require('../users'); const SETTINGS_FILE = 'settings.json'; -const enableExtensions = getConfigValue('enableExtensions', true); +const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true); +const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { const files = fs @@ -163,7 +164,8 @@ router.post('/get', jsonParser, (request, response) => { quickReplyPresets, instruct, context, - enable_extensions: enableExtensions, + enable_extensions: ENABLE_EXTENSIONS, + enable_accounts: ENABLE_ACCOUNTS, }); }); diff --git a/src/users.js b/src/users.js index 9c8ee7e2d..022d6f904 100644 --- a/src/users.js +++ b/src/users.js @@ -604,7 +604,7 @@ const publicEndpoints = express.Router(); publicEndpoints.get('/list', async (_request, response) => { /** @type {User[]} */ - const users = await storage.values(); + const users = await storage.values(x => x.key.startsWith(KEY_PREFIX)); const viewModels = users .filter(x => x.enabled) .sort((x, y) => x.created - y.created) @@ -612,7 +612,6 @@ publicEndpoints.get('/list', async (_request, response) => { handle: user.handle, name: user.name, avatar: getUserAvatar(user.handle), - admin: user.admin, password: !!user.password, })); @@ -672,7 +671,7 @@ publicEndpoints.post('/recover-step1', jsonParser, async (request, response) => return response.status(403).json({ error: 'User is disabled' }); } - const mfaCode = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); + const mfaCode = String(crypto.randomInt(1000, 9999)); console.log(); console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode)); console.log(); @@ -706,20 +705,30 @@ publicEndpoints.post('/recover-step2', jsonParser, async (request, response) => return response.status(401).json({ error: 'Incorrect code' }); } - const newPassword = request.body.newPassword || ''; - const salt = getPasswordSalt(); - user.password = getPasswordHash(newPassword, salt); - user.salt = salt; - await storage.setItem(toKey(user.handle), user); + if (request.body.newPassword) { + const salt = getPasswordSalt(); + user.password = getPasswordHash(request.body.newPassword, salt); + user.salt = salt; + await storage.setItem(toKey(user.handle), user); + } else { + user.password = ''; + user.salt = ''; + await storage.setItem(toKey(user.handle), user); + } + return response.sendStatus(204); }); const authenticatedEndpoints = express.Router(); authenticatedEndpoints.post('/logout', async (request, response) => { - request.session?.destroy(() => { - return response.sendStatus(204); - }); + if (!request.session) { + console.error('Session not available'); + return response.sendStatus(500); + } + + request.session.handle = null; + return response.sendStatus(204); }); authenticatedEndpoints.get('/me', async (request, response) => { @@ -772,6 +781,25 @@ authenticatedEndpoints.post('/change-password', jsonParser, async (request, resp const adminEndpoints = express.Router(); +adminEndpoints.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => { + /** @type {User[]} */ + const users = await storage.values(x => x.key.startsWith(KEY_PREFIX)); + + const viewModels = users + .sort((x, y) => x.created - y.created) + .map(user => ({ + handle: user.handle, + name: user.name, + avatar: getUserAvatar(user.handle), + admin: user.admin, + enabled: user.enabled, + created: user.created, + password: !!user.password, + })); + + return response.json(viewModels); +}); + adminEndpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => { if (!request.body.handle) { console.log('Disable user failed: Missing required fields');