Basic account management

This commit is contained in:
Cohee
2024-04-08 02:38:20 +03:00
parent 3f3e23420d
commit 72792ae9f9
8 changed files with 383 additions and 184 deletions

View File

@ -21,7 +21,7 @@ basicAuthUser:
# Enables CORS proxy middleware # Enables CORS proxy middleware
enableCorsProxy: false enableCorsProxy: false
# Enable multi-user mode # Enable multi-user mode
enableUserAccounts: true enableUserAccounts: false
# Used to sign session cookies. Will be auto-generated if not set # Used to sign session cookies. Will be auto-generated if not set
cookieSecret: '' cookieSecret: ''
# Disable security checks - NOT RECOMMENDED # Disable security checks - NOT RECOMMENDED

View File

@ -88,6 +88,7 @@
<script type="module" src="scripts/bulk-edit.js"></script> <script type="module" src="scripts/bulk-edit.js"></script>
<script type="module" src="scripts/cfg-scale.js"></script> <script type="module" src="scripts/cfg-scale.js"></script>
<script type="module" src="scripts/chats.js"></script> <script type="module" src="scripts/chats.js"></script>
<script type="module" src="scripts/user.js"></script>
<title>SillyTavern</title> <title>SillyTavern</title>
</head> </head>
@ -3452,7 +3453,21 @@
<small id="version_display"></small> <small id="version_display"></small>
</div> </div>
<div name="UserSettingsRowTwo" class="flex-container flexFlowRow"> <div name="UserSettingsRowTwo" class="flex-container flexFlowRow">
<textarea id="settingsSearch" class="textarea_compact wide100p" rows="1" placeholder="Search Settings" data-i18n="[placeholder]Search Settings"></textarea> <div id="account_controls" class="flex-container">
<div id="account_button" class="margin0 menu_button_icon menu_button">
<i class="fa-fw fa-solid fa-user-shield"></i>
<span data-i18n="Account">Account</span>
</div>
<div id="admin_button" class="margin0 menu_button_icon menu_button" >
<i class="fa-fw fa-solid fa-user-tie"></i>
<span data-i18n="Admin Panel">Admin Panel</span>
</div>
<div id="logout_button" class="margin0 menu_button_icon menu_button">
<i class="fa-fw fa-solid fa-right-from-bracket"></i>
<span data-i18n="Logout">Logout</span>
</div>
</div>
<textarea id="settingsSearch" class="textarea_compact flex1" rows="1" placeholder="Search Settings" data-i18n="[placeholder]Search Settings"></textarea>
</div> </div>
</div> </div>
<div id="user-settings-block-content" class="flex-container spaceEvenly"> <div id="user-settings-block-content" class="flex-container spaceEvenly">
@ -5277,17 +5292,16 @@
Enable simple UI mode Enable simple UI mode
</span> </span>
</label> </label>
<h3 data-i18n="Your Persona">
Your Persona
</h3>
<div class="justifyLeft margin-bot-10px"> <div class="justifyLeft margin-bot-10px">
<span data-i18n="Before you get started, you must select a user name."> <span data-i18n="Before you get started, you must select a persona name.">
Before you get started, you must select a user name. Before you get started, you must select a persona name.
</span> </span>
This can be changed at any time via the <code><i class="fa-solid fa-face-smile"></i></code> icon. This can be changed at any time via the <code><i class="fa-solid fa-face-smile"></i></code> icon.
</div> </div>
<h4 data-i18n="UI Language:">UI Language:</h4> <h4 data-i18n="Persona Name:">Persona Name:</h4>
<select name="onboarding_ui_language">
<option value="en">English</option>
</select>
<h4 data-i18n="User Name:">User Name:</h4>
</div> </div>
</div> </div>
<div id="group_member_template" class="template_element"> <div id="group_member_template" class="template_element">

View File

@ -211,6 +211,7 @@ import { loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermati
import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js'; import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js';
import { initPresetManager } from './scripts/preset-manager.js'; import { initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros } from './scripts/macros.js'; import { evaluateMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
//exporting functions and vars for mods //exporting functions and vars for mods
export { export {
@ -6097,7 +6098,7 @@ async function doOnboarding(avatarId) {
template.find('input[name="enable_simple_mode"]').on('input', function () { template.find('input[name="enable_simple_mode"]').on('input', function () {
simpleUiMode = $(this).is(':checked'); simpleUiMode = $(this).is(':checked');
}); });
var userName = await callPopup(template, 'input', name1); let userName = await callPopup(template, 'input', currentUser?.name || name1);
if (userName) { if (userName) {
userName = userName.replace('\n', ' '); userName = userName.replace('\n', ' ');
@ -6151,6 +6152,8 @@ async function getSettings() {
$('#your_name').val(name1); $('#your_name').val(name1);
} }
await setUserControls(data.enable_accounts);
// Allow subscribers to mutate settings // Allow subscribers to mutate settings
eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings); eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);

View File

@ -0,0 +1,77 @@
<div class="adminTabs wide100p">
<nav class="adminNav flex-container alignItemsCenter justifyCenter">
<button type="button" class="manageUsersButton menu_button menu_button_icon" data-target-tab="usersList">
<h4>Manage Users</h4>
</button>
<button type="button" class="newUserButton menu_button menu_button_icon" data-target-tab="registerNewUserBlock">
<h4>New User</h4>
</button>
</nav>
<div class="userAccountTemplate template_element">
<div class="flex-container userAccount alignItemsCenter flexGap10">
<div>
<div class="avatar">
<img src="img/ai4.png" alt="avatar">
</div>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap justifyLeft">
<div class="flex-container flexGap10 alignItemsCenter">
<i class="hasPassword fa-solid fa-lock"></i>
<i class="noPassword fa-solid fa-lock-open"></i>
<h3 class="userName margin0"></h3>
<small class="userHandle">@userhandle</small>
</div>
<div class="flex-container flexFlowColumn flexNoGap">
<span>
<span>Role:</span>
<span class="userRole"></span>
</span>
<span>
<span>Status:</span>
<span class="userStatus">Status</span>
</span>
<span>
<span>Created:</span>
<span class="userCreated">Date</span>
</span>
</div>
</div>
<div class="flex-container">
<div class="userEditButton menu_button">Edit</div>
<div class="userDisableButton menu_button">Disable</div>
<div class="userEnableButton menu_button">Enable</div>
</div>
</div>
</div>
<div class="navTab usersList flex-container flexFlowColumn">
</div>
<div class="navTab registerNewUserBlock" style="display: none;">
<form class="flex-container flexFlowColumn flexGap10 userCreateForm" action="javascript:void(0);">
<h3>
Register New SillyTavern User
</h3>
<div class="flex-container flexNoGap">
<span>User Handle:</span>
<input name="handle" class="text_pole" placeholder="Lowercase letters, numbers, and dashes only." type="text" pattern="[a-z0-9-]+">
</div>
<div class="flex-container flexNoGap">
<span>Display Name:</span>
<input name="_name" class="text_pole" type="text" placeholder="Anonymous">
</div>
<div class="flex-container flexNoGap">
<span>Password:</span>
<input name="password" class="text_pole" type="password" placeholder="[ No password ]">
</div>
<div class="flex-container flexNoGap">
<span>Confirm Password:</span>
<input name="confirm" class="text_pole" type="password" placeholder="[ No password ]">
</div>
<span>
This will create a new subfolder in the /data/ directory with the user's handle as the folder name.
</span>
<div class="flex-container justifyCenter">
<button type="submit" class="menu_button newUserRegisterFinalizeButton">Create</div>
</div>
</form>
</div>
</div>

236
public/scripts/user.js Normal file
View File

@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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();
});
});

View File

@ -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 = `<div id="registerNewUserButton" class="menu_button flex-container flexFlowCol">New User</div>`
const newUserRegisterationHTML = `
<div class="flex-container flexFlowColumn">
Register New SillyTavern User
<div class="flex-container">Username: <input id="newUserHandle" class="text_pole"></div>
<div class="flex-container">Display Name: <input id="newUserName" class="text_pole"></div>
<div class="flex-container">Password: <input id="newUserPassword" class="text_pole" type="password"></div>
<div class="flex-container">Password confirm: <input id="newUserPasswordConfirm" class="text_pole" type="password"></div>
This will create a new subfolder in the /data/ directory.
<div class="flex-container">
<div id="newUserRegisterFinalizeButton" class="menu_button">Register</div>
<div id="newUserRegisterCancelButton" class="menu_button">Cancel</div>
</div>
</div>
`
const userSelectHTML = `
<div id="registerNewUserBlock" style="display:none;">
${newUserRegisterationHTML}
</div>
</div>
`;
// Add login screen
$('#loader').append(userSelectHTML);
const parentDiv = $('#userList');
userList.forEach(user => {
const userDiv = $('<div></div>')
.attr('id', `userSelect-${user.handle}`)
.attr('data-foruser', user.name)
.addClass('userSelect menu_button flex-container flexFlowCol');
const avatarImg = $('<img>')
.addClass('avatar')
.attr('src', user.avatar);
userDiv.append(avatarImg);
const userName = $('<span></span>').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)
}

View File

@ -8,7 +8,8 @@ const { jsonParser } = require('../express-common');
const { getAllUserHandles, getUserDirectories } = require('../users'); const { getAllUserHandles, getUserDirectories } = require('../users');
const SETTINGS_FILE = 'settings.json'; 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') { function readAndParseFromDirectory(directoryPath, fileExtension = '.json') {
const files = fs const files = fs
@ -163,7 +164,8 @@ router.post('/get', jsonParser, (request, response) => {
quickReplyPresets, quickReplyPresets,
instruct, instruct,
context, context,
enable_extensions: enableExtensions, enable_extensions: ENABLE_EXTENSIONS,
enable_accounts: ENABLE_ACCOUNTS,
}); });
}); });

View File

@ -604,7 +604,7 @@ const publicEndpoints = express.Router();
publicEndpoints.get('/list', async (_request, response) => { publicEndpoints.get('/list', async (_request, response) => {
/** @type {User[]} */ /** @type {User[]} */
const users = await storage.values(); const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
const viewModels = users const viewModels = users
.filter(x => x.enabled) .filter(x => x.enabled)
.sort((x, y) => x.created - y.created) .sort((x, y) => x.created - y.created)
@ -612,7 +612,6 @@ publicEndpoints.get('/list', async (_request, response) => {
handle: user.handle, handle: user.handle,
name: user.name, name: user.name,
avatar: getUserAvatar(user.handle), avatar: getUserAvatar(user.handle),
admin: user.admin,
password: !!user.password, password: !!user.password,
})); }));
@ -672,7 +671,7 @@ publicEndpoints.post('/recover-step1', jsonParser, async (request, response) =>
return response.status(403).json({ error: 'User is disabled' }); 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();
console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode)); console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
console.log(); console.log();
@ -706,20 +705,30 @@ publicEndpoints.post('/recover-step2', jsonParser, async (request, response) =>
return response.status(401).json({ error: 'Incorrect code' }); return response.status(401).json({ error: 'Incorrect code' });
} }
const newPassword = request.body.newPassword || ''; if (request.body.newPassword) {
const salt = getPasswordSalt(); const salt = getPasswordSalt();
user.password = getPasswordHash(newPassword, salt); user.password = getPasswordHash(request.body.newPassword, salt);
user.salt = salt; user.salt = salt;
await storage.setItem(toKey(user.handle), user); await storage.setItem(toKey(user.handle), user);
} else {
user.password = '';
user.salt = '';
await storage.setItem(toKey(user.handle), user);
}
return response.sendStatus(204); return response.sendStatus(204);
}); });
const authenticatedEndpoints = express.Router(); const authenticatedEndpoints = express.Router();
authenticatedEndpoints.post('/logout', async (request, response) => { authenticatedEndpoints.post('/logout', async (request, response) => {
request.session?.destroy(() => { if (!request.session) {
console.error('Session not available');
return response.sendStatus(500);
}
request.session.handle = null;
return response.sendStatus(204); return response.sendStatus(204);
});
}); });
authenticatedEndpoints.get('/me', async (request, response) => { authenticatedEndpoints.get('/me', async (request, response) => {
@ -772,6 +781,25 @@ authenticatedEndpoints.post('/change-password', jsonParser, async (request, resp
const adminEndpoints = express.Router(); 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) => { adminEndpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
if (!request.body.handle) { if (!request.body.handle) {
console.log('Disable user failed: Missing required fields'); console.log('Disable user failed: Missing required fields');