Customizable avatars for users
This commit is contained in:
parent
10aa268ea2
commit
1a372abaff
|
@ -20,7 +20,7 @@ body.login .userSelect {
|
|||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 5px;
|
||||
padding: 3px 5px;
|
||||
width: min-content;
|
||||
width: 30%;
|
||||
cursor: pointer;
|
||||
margin: 5px 0;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
|
@ -28,6 +28,15 @@ body.login .userSelect {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.login .userSelect .userName,
|
||||
body.login .userSelect .userHandle {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.login .userSelect:hover {
|
||||
|
|
|
@ -98,6 +98,14 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justifySpaceEvenly {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.justifySpaceAround {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.alignitemsflexstart {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
Enter Login Details
|
||||
</h3>
|
||||
<div id="userListBlock" class="wide100p">
|
||||
<div id="userList" class="flex-container justifyCenter"></div>
|
||||
<div id="userList" class="flex-container justifySpaceEvenly"></div>
|
||||
<div id="handleEntryBlock" style="display:none;" class="flex-container flexFlowColumn alignItemsCenter">
|
||||
<input id="userHandle" class="text_pole" type="text" placeholder="User handle" autocomplete="username">
|
||||
</div>
|
||||
|
|
|
@ -7073,10 +7073,10 @@ function onScenarioOverrideRemoveClick() {
|
|||
* @param {string} type
|
||||
* @param {string} inputValue - Value to set the input to.
|
||||
* @param {PopupOptions} options - Options for the popup.
|
||||
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup.
|
||||
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
|
||||
* @returns
|
||||
*/
|
||||
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) {
|
||||
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
|
||||
dialogueCloseStop = true;
|
||||
if (type) {
|
||||
popup_type = type;
|
||||
|
@ -7133,7 +7133,7 @@ function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, a
|
|||
crop_data = undefined;
|
||||
|
||||
$('#avatarToCrop').cropper({
|
||||
aspectRatio: 2 / 3,
|
||||
aspectRatio: cropAspect ?? 2 / 3,
|
||||
autoCropArea: 1,
|
||||
viewMode: 2,
|
||||
rotatable: false,
|
||||
|
|
|
@ -215,8 +215,8 @@ function configureNormalLogin(userList) {
|
|||
const avatarBlock = $('<div></div>').addClass('avatar');
|
||||
avatarBlock.append($('<img>').attr('src', user.avatar));
|
||||
userBlock.append(avatarBlock);
|
||||
userBlock.append($('<span></span>').text(user.name));
|
||||
userBlock.append($('<small></small>').text(user.handle));
|
||||
userBlock.append($('<span></span>').addClass('userName').text(user.name));
|
||||
userBlock.append($('<small></small>').addClass('userHandle').text(user.handle));
|
||||
userBlock.on('click', () => onUserSelected(user));
|
||||
$('#userList').append(userBlock);
|
||||
}
|
||||
|
|
|
@ -9,10 +9,21 @@
|
|||
</nav>
|
||||
<div class="userAccountTemplate template_element">
|
||||
<div class="flex-container userAccount alignItemsCenter flexGap10">
|
||||
<div>
|
||||
<div class="avatar">
|
||||
<div class="flex-container flexFlowColumn alignItemsCenter flexNoGap">
|
||||
<div class="avatar" title="If a custom avatar is not set, the user's default persona image will be displayed.">
|
||||
<img src="img/ai4.png" alt="avatar">
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter">
|
||||
<div class="userAvatarChange right_menu_button" title="Set a custom avatar.">
|
||||
<i class="fa-fw fa-solid fa-image"></i>
|
||||
</div>
|
||||
<div class="userAvatarRemove right_menu_button" title="Remove a custom avatar.">
|
||||
<i class="fa-fw fa-solid fa-trash"></i>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<input type="file" class="avatarUpload" accept="image/*" hidden>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex1 flex-container flexFlowColumn flexNoGap justifyLeft">
|
||||
<div class="flex-container flexGap10 alignItemsCenter">
|
||||
|
|
|
@ -15,10 +15,21 @@
|
|||
Account Info
|
||||
</h3>
|
||||
<div class="flex-container flexGap10">
|
||||
<div>
|
||||
<div class="avatar" title="To change your user avatar, select a default persona in the Persona Management menu.">
|
||||
<div class="flex-container flexFlowColumn alignItemsCenter flexNoGap">
|
||||
<div class="avatar" title="To change your user avatar, use the buttons below or select a default persona in the Persona Management menu.">
|
||||
<img src="img/ai4.png" alt="avatar">
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter">
|
||||
<div class="userAvatarChange right_menu_button" title="Set your custom avatar.">
|
||||
<i class="fa-fw fa-solid fa-image"></i>
|
||||
</div>
|
||||
<div class="userAvatarRemove right_menu_button" title="Remove your custom avatar.">
|
||||
<i class="fa-fw fa-solid fa-trash"></i>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<input type="file" class="avatarUpload" accept="image/*" hidden>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex1 flex-container flexGap10">
|
||||
<div class="flex-container flexFlowColumn">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { getRequestHeaders } from '../script.js';
|
||||
import { callPopup, getCropPopup, getRequestHeaders } from '../script.js';
|
||||
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
|
||||
import { renderTemplateAsync } from './templates.js';
|
||||
import { humanFileSize } from './utils.js';
|
||||
import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './utils.js';
|
||||
|
||||
/**
|
||||
* @type {import('../../src/users.js').UserViewModel} Logged in user
|
||||
|
@ -683,6 +683,26 @@ async function openUserProfile() {
|
|||
});
|
||||
template.find('.userResetSettingsButton').on('click', () => resetSettings(currentUser.handle, () => location.reload()));
|
||||
template.find('.userResetAllButton').on('click', () => resetEverything(() => location.reload()));
|
||||
template.find('.userAvatarChange').on('click', () => template.find('.avatarUpload').trigger('click'));
|
||||
template.find('.avatarUpload').on('change', async function () {
|
||||
if (!(this instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = this.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cropAndUploadAvatar(currentUser.handle, file);
|
||||
await getCurrentUser();
|
||||
template.find('.avatar img').attr('src', currentUser.avatar);
|
||||
});
|
||||
template.find('.userAvatarRemove').on('click', async function () {
|
||||
await changeAvatar(currentUser.handle, '');
|
||||
await getCurrentUser();
|
||||
template.find('.avatar img').attr('src', currentUser.avatar);
|
||||
});
|
||||
|
||||
if (!accountsEnabled) {
|
||||
template.find('[data-require-accounts]').hide();
|
||||
|
@ -699,6 +719,48 @@ async function openUserProfile() {
|
|||
callGenericPopup(template, POPUP_TYPE.TEXT, '', popupOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop and upload an avatar image.
|
||||
* @param {string} handle User handle
|
||||
* @param {File} file Avatar file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function cropAndUploadAvatar(handle, file) {
|
||||
const dataUrl = await getBase64Async(await ensureImageFormatSupported(file));
|
||||
const croppedImage = await callPopup(getCropPopup(dataUrl), 'avatarToCrop', '', { cropAspect: 1 });
|
||||
if (!croppedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await changeAvatar(handle, String(croppedImage));
|
||||
|
||||
return croppedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the avatar of the user.
|
||||
* @param {string} handle User handle
|
||||
* @param {string} avatar File to upload or base64 string
|
||||
* @returns {Promise<void>} Avatar URL
|
||||
*/
|
||||
async function changeAvatar(handle, avatar) {
|
||||
try {
|
||||
const response = await fetch('/api/users/change-avatar', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ avatar, handle }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toastr.error(data.error || 'Unknown error', 'Failed to change avatar');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error changing avatar:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAdminPanel() {
|
||||
async function renderUsers() {
|
||||
const users = await getUsers();
|
||||
|
@ -724,6 +786,24 @@ async function openAdminPanel() {
|
|||
$(this).addClass('disabled').off('click');
|
||||
backupUserData(user.handle, renderUsers);
|
||||
});
|
||||
userBlock.find('.userAvatarChange').on('click', () => userBlock.find('.avatarUpload').trigger('click'));
|
||||
userBlock.find('.avatarUpload').on('change', async function () {
|
||||
if (!(this instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = this.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cropAndUploadAvatar(user.handle, file);
|
||||
renderUsers();
|
||||
});
|
||||
userBlock.find('.userAvatarRemove').on('click', async function () {
|
||||
await changeAvatar(user.handle, '');
|
||||
renderUsers();
|
||||
});
|
||||
template.find('.usersList').append(userBlock);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,23 +19,29 @@ const { DEFAULT_USER } = require('../constants');
|
|||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
router.post('/get', requireAdminMiddleware, jsonParser, async (_request, response) => {
|
||||
try {
|
||||
/** @type {import('../users').User[]} */
|
||||
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
||||
|
||||
const viewModels = users
|
||||
.sort((x, y) => x.created - y.created)
|
||||
.map(user => ({
|
||||
/** @type {Promise<import('../users').UserViewModel>[]} */
|
||||
const viewModelPromises = users
|
||||
.map(user => new Promise(resolve => {
|
||||
getUserAvatar(user.handle).then(avatar =>
|
||||
resolve({
|
||||
handle: user.handle,
|
||||
name: user.name,
|
||||
avatar: getUserAvatar(user.handle),
|
||||
avatar: avatar,
|
||||
admin: user.admin,
|
||||
enabled: user.enabled,
|
||||
created: user.created,
|
||||
password: !!user.password,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const viewModels = await Promise.all(viewModelPromises);
|
||||
viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
|
||||
return response.json(viewModels);
|
||||
} catch (error) {
|
||||
console.error('User list failed:', error);
|
||||
|
|
|
@ -4,7 +4,7 @@ const storage = require('node-persist');
|
|||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { jsonParser } = require('../express-common');
|
||||
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist } = require('../users');
|
||||
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } = require('../users');
|
||||
const { SETTINGS_FILE } = require('../constants');
|
||||
const contentManager = require('./content-manager');
|
||||
const { color, Cache } = require('../util');
|
||||
|
@ -39,7 +39,7 @@ router.get('/me', async (request, response) => {
|
|||
const viewModel = {
|
||||
handle: user.handle,
|
||||
name: user.name,
|
||||
avatar: getUserAvatar(user.handle),
|
||||
avatar: await getUserAvatar(user.handle),
|
||||
admin: user.admin,
|
||||
password: !!user.password,
|
||||
created: user.created,
|
||||
|
@ -52,6 +52,41 @@ router.get('/me', async (request, response) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/change-avatar', jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle) {
|
||||
console.log('Change avatar failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
|
||||
console.log('Change avatar failed: Unauthorized');
|
||||
return response.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Avatar is not a data URL or not an empty string
|
||||
if (!request.body.avatar.startsWith('data:image/') && request.body.avatar !== '') {
|
||||
console.log('Change avatar failed: Invalid data URL');
|
||||
return response.status(400).json({ error: 'Invalid data URL' });
|
||||
}
|
||||
|
||||
/** @type {import('../users').User} */
|
||||
const user = await storage.getItem(toKey(request.body.handle));
|
||||
|
||||
if (!user) {
|
||||
console.log('Change avatar failed: User not found');
|
||||
return response.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
await storage.setItem(toAvatarKey(request.body.handle), request.body.avatar);
|
||||
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/change-password', jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle) {
|
||||
|
@ -185,7 +220,7 @@ router.post('/reset-step1', jsonParser, async (request, response) => {
|
|||
});
|
||||
|
||||
router.post('/reset-step2', jsonParser, async (request, response) => {
|
||||
try{
|
||||
try {
|
||||
if (!request.body.code) {
|
||||
console.log('Recover step 2 failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
|
|
|
@ -27,16 +27,24 @@ router.post('/list', async (_request, response) => {
|
|||
|
||||
/** @type {import('../users').User[]} */
|
||||
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
||||
const viewModels = users
|
||||
|
||||
/** @type {Promise<import('../users').UserViewModel>[]} */
|
||||
const viewModelPromises = users
|
||||
.filter(x => x.enabled)
|
||||
.sort((x, y) => x.created - y.created)
|
||||
.map(user => ({
|
||||
.map(user => new Promise(async (resolve) => {
|
||||
getUserAvatar(user.handle).then(avatar =>
|
||||
resolve({
|
||||
handle: user.handle,
|
||||
name: user.name,
|
||||
avatar: getUserAvatar(user.handle),
|
||||
created: user.created,
|
||||
avatar: avatar,
|
||||
password: !!user.password,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const viewModels = await Promise.all(viewModelPromises);
|
||||
viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
|
||||
return response.json(viewModels);
|
||||
} catch (error) {
|
||||
console.error('User list failed:', error);
|
||||
|
|
26
src/users.js
26
src/users.js
|
@ -15,6 +15,7 @@ const { getConfigValue, color, delay, setConfigValue, generateTimestamp } = requ
|
|||
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
||||
|
||||
const KEY_PREFIX = 'user:';
|
||||
const AVATAR_PREFIX = 'avatar:';
|
||||
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
||||
const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
|
||||
|
||||
|
@ -51,7 +52,7 @@ const STORAGE_KEYS = {
|
|||
* @property {string} handle - The user's short handle. Used for directories and other references
|
||||
* @property {string} name - The user's name. Displayed in the UI
|
||||
* @property {string} avatar - The user's avatar image
|
||||
* @property {boolean} admin - Whether the user is an admin (can manage other users)
|
||||
* @property {boolean} [admin] - Whether the user is an admin (can manage other users)
|
||||
* @property {boolean} password - Whether the user is password protected
|
||||
* @property {boolean} [enabled] - Whether the user is enabled
|
||||
* @property {number} [created] - The timestamp when the user was created
|
||||
|
@ -315,6 +316,15 @@ function toKey(handle) {
|
|||
return `${KEY_PREFIX}${handle}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a user handle to a storage key for avatars.
|
||||
* @param {string} handle User handle
|
||||
* @returns {string} The key for the avatar storage
|
||||
*/
|
||||
function toAvatarKey(handle) {
|
||||
return `${AVATAR_PREFIX}${handle}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the user storage. Currently a no-op.
|
||||
* @param {string} dataRoot The root directory for user data
|
||||
|
@ -435,10 +445,19 @@ function getUserDirectories(handle) {
|
|||
/**
|
||||
* Gets the avatar URL for the provided user.
|
||||
* @param {string} handle User handle
|
||||
* @returns {string} User avatar URL
|
||||
* @returns {Promise<string>} User avatar URL
|
||||
*/
|
||||
function getUserAvatar(handle) {
|
||||
async function getUserAvatar(handle) {
|
||||
try {
|
||||
// Check if the user has a custom avatar
|
||||
const avatarKey = toAvatarKey(handle);
|
||||
const avatar = await storage.getItem(avatarKey);
|
||||
|
||||
if (avatar) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
// Fallback to reading from files if custom avatar is not set
|
||||
const directory = getUserDirectories(handle);
|
||||
const pathToSettings = path.join(directory.root, SETTINGS_FILE);
|
||||
const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
|
||||
|
@ -665,6 +684,7 @@ router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.us
|
|||
module.exports = {
|
||||
KEY_PREFIX,
|
||||
toKey,
|
||||
toAvatarKey,
|
||||
initUserStorage,
|
||||
ensurePublicDirectoriesExist,
|
||||
getAllUserHandles,
|
||||
|
|
Loading…
Reference in New Issue