feat: Integrate official drive_provider, update user profile features & UI improvements
All checks were successful
CI / container-job (push) Successful in 41s
All checks were successful
CI / container-job (push) Successful in 41s
- adonisrc.ts: Load official drive_provider and unload custom driver_provider. - packages.json: Add @headlessui/vue dependency for tab components. - AvatarController.ts: Rewrite avatar generation logic to always return the same avatar per user. - auth/UserController.ts: Add profile and profileUpdate methods to support user profile editing. - Submitter/datasetController.ts & app/models/file.ts: Adapt code to use the official drive_provider. - app/models/user.ts: Introduce “isAdmin” getter. - config/drive.ts: Create new configuration for the official drive_provider. - providers/vinejs_provider.ts: Adapt allowedExtensions control to use provided options or database enabled extensions. - resource/js/app.ts: Load default Head and Link components. - resources/js/menu.ts: Add settings-profile.edit menu point. - resources/js/Components/action-message.vue: Add new component for improved user feedback after form submissions. - New avatar-input.vue component: Enable profile picture selection. - Components/CardBox.vue: Alter layout to optionally show HeaderIcon in title bar. - FormControl.vue: Define a readonly prop for textareas. - Improve overall UI with updates to NavBar.vue, UserAvatar.vue, UserAvatarCurrentUser.vue, and add v-model support to password-meter.vue. - Remove profile editing logic from AccountInfo.vue and introduce new profile components (show.vue, update-password-form.vue, update-profile-information.vue). - app.edge: Modify page (add @inertiaHead tag) for better meta management. - routes.ts: Add new routes for editing user profiles. - General npm updates.
This commit is contained in:
parent
a41b091214
commit
36cd7a757b
34 changed files with 1396 additions and 407 deletions
|
@ -1,34 +1,28 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
// import * as fs from 'fs';
|
||||
// import * as path from 'path';
|
||||
|
||||
const prefixes = ['von', 'van'];
|
||||
|
||||
// node ace make:controller Author
|
||||
export default class AvatarController {
|
||||
public async generateAvatar({ request, response }: HttpContext) {
|
||||
try {
|
||||
const { name, background, textColor, size } = request.only(['name', 'background', 'textColor', 'size']);
|
||||
const { name, size } = request.only(['name', 'size']);
|
||||
|
||||
// Generate initials
|
||||
// const initials = name
|
||||
// .split(' ')
|
||||
// .map((part) => part.charAt(0).toUpperCase())
|
||||
// .join('');
|
||||
const initials = this.getInitials(name);
|
||||
|
||||
// Define SVG content with dynamic values for initials, background color, text color, and size
|
||||
const originalColor = this.getColorFromName(name);
|
||||
const backgroundColor = this.lightenColor(originalColor, 60);
|
||||
const textColor = this.darkenColor(originalColor);
|
||||
|
||||
const svgContent = `
|
||||
<svg width="${size || 50}" height="${size || 50}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#${background || '7F9CF5'}"/>
|
||||
<rect width="100%" height="100%" fill="#${backgroundColor}"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Arial, sans-serif" font-size="${
|
||||
(size / 100) * 40 || 25
|
||||
}" fill="#${textColor || 'ffffff'}">${initials}</text>
|
||||
}" fill="#${textColor}">${initials}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Set response headers for SVG content
|
||||
response.header('Content-type', 'image/svg+xml');
|
||||
response.header('Cache-Control', 'no-cache');
|
||||
response.header('Pragma', 'no-cache');
|
||||
|
@ -62,4 +56,49 @@ export default class AvatarController {
|
|||
|
||||
return initials;
|
||||
}
|
||||
|
||||
private getColorFromName(name: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ('00' + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color.replace('#', '');
|
||||
}
|
||||
|
||||
private lightenColor(hexColor: string, percent: number) {
|
||||
let r = parseInt(hexColor.substring(0, 2), 16);
|
||||
let g = parseInt(hexColor.substring(2, 4), 16);
|
||||
let b = parseInt(hexColor.substring(4, 6), 16);
|
||||
|
||||
r = Math.floor((r * (100 + percent)) / 100);
|
||||
g = Math.floor((g * (100 + percent)) / 100);
|
||||
b = Math.floor((b * (100 + percent)) / 100);
|
||||
|
||||
r = r < 255 ? r : 255;
|
||||
g = g < 255 ? g : 255;
|
||||
b = b < 255 ? b : 255;
|
||||
|
||||
const lighterHex = ((r << 16) | (g << 8) | b).toString(16);
|
||||
|
||||
return lighterHex.padStart(6, '0');
|
||||
}
|
||||
|
||||
private darkenColor(hexColor: string) {
|
||||
const r = parseInt(hexColor.slice(0, 2), 16);
|
||||
const g = parseInt(hexColor.slice(2, 4), 16);
|
||||
const b = parseInt(hexColor.slice(4, 6), 16);
|
||||
|
||||
const darkerR = Math.round(r * 0.6);
|
||||
const darkerG = Math.round(g * 0.6);
|
||||
const darkerB = Math.round(b * 0.6);
|
||||
|
||||
const darkerColor = ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16);
|
||||
|
||||
return darkerColor.padStart(6, '0');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,11 @@ import hash from '@adonisjs/core/services/hash';
|
|||
// import { schema, rules } from '@adonisjs/validator';
|
||||
import vine from '@vinejs/vine';
|
||||
import BackupCodeStorage, { SecureRandom } from '#services/backup_code_storage';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
// import drive from '#services/drive';
|
||||
import drive from '@adonisjs/drive/services/main';
|
||||
import logger from '@adonisjs/core/services/logger';
|
||||
|
||||
// Here we are generating secret and recovery codes for the user that’s enabling 2FA and storing them to our database.
|
||||
export default class UserController {
|
||||
|
@ -28,7 +33,7 @@ export default class UserController {
|
|||
user: user,
|
||||
twoFactorEnabled: user.isTwoFactorEnabled,
|
||||
// code: await TwoFactorAuthProvider.generateQrCode(user),
|
||||
backupState: backupState,
|
||||
backupState: backupState,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -40,10 +45,8 @@ export default class UserController {
|
|||
// });
|
||||
const passwordSchema = vine.object({
|
||||
// first step
|
||||
old_password: vine
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-zA-Z0-9]+$/),
|
||||
old_password: vine.string().trim(),
|
||||
// .regex(/^[a-zA-Z0-9]+$/),
|
||||
new_password: vine.string().confirmed({ confirmationField: 'confirm_password' }).trim().minLength(8).maxLength(255),
|
||||
});
|
||||
try {
|
||||
|
@ -54,9 +57,9 @@ export default class UserController {
|
|||
// return response.badRequest(error.messages);
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const user = await auth.user as User;
|
||||
const user = (await auth.user) as User;
|
||||
const { old_password, new_password } = request.only(['old_password', 'new_password']);
|
||||
|
||||
// if (!(old_password && new_password && confirm_password)) {
|
||||
|
@ -82,6 +85,171 @@ export default class UserController {
|
|||
}
|
||||
}
|
||||
|
||||
public async profile({ inertia, auth }: HttpContext) {
|
||||
const user = await User.find(auth.user?.id);
|
||||
// let test = await drive.use().getUrl(user?.avatar);
|
||||
// user?.preload('roles');
|
||||
const avatarFullPathUrl = user?.avatar ? await drive.use('public').getUrl(user.avatar) : null;
|
||||
return inertia.render('profile/show', {
|
||||
user: user,
|
||||
defaultUrl: avatarFullPathUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*
|
||||
* @param {HttpContext} ctx - The HTTP context object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async profileUpdate({ auth, request, response, session }: HttpContext) {
|
||||
if (!auth.user) {
|
||||
session.flash('error', 'You must be logged in to update your profile.');
|
||||
return response.redirect().toRoute('login');
|
||||
}
|
||||
|
||||
const updateProfileValidator = vine.withMetaData<{ userId: number }>().compile(
|
||||
vine.object({
|
||||
first_name: vine.string().trim().minLength(4).maxLength(255),
|
||||
last_name: vine.string().trim().minLength(4).maxLength(255),
|
||||
login: vine.string().trim().minLength(4).maxLength(255),
|
||||
email: vine
|
||||
.string()
|
||||
.trim()
|
||||
.maxLength(255)
|
||||
.email()
|
||||
.normalizeEmail()
|
||||
.isUnique({ table: 'accounts', column: 'email', whereNot: (field) => field.meta.userId }),
|
||||
avatar: vine
|
||||
.myfile({
|
||||
size: '2mb',
|
||||
extnames: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
})
|
||||
// .allowedMimetypeExtensions({
|
||||
// allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
// })
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await User.find(auth.user.id);
|
||||
if (!user) {
|
||||
session.flash('error', 'User not found.');
|
||||
return response.redirect().toRoute('login');
|
||||
}
|
||||
|
||||
try {
|
||||
// validate update form
|
||||
await request.validateUsing(updateProfileValidator, {
|
||||
meta: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { login, email, first_name, last_name } = request.only(['login', 'email', 'first_name', 'last_name']);
|
||||
const sanitizedData: { [key: string]: any } = {
|
||||
login: login?.trim(),
|
||||
email: email?.toLowerCase().trim(),
|
||||
first_name: first_name?.trim(),
|
||||
last_name: last_name?.trim(),
|
||||
// avatar: "",
|
||||
};
|
||||
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const hasInputChanges = Object.keys(sanitizedData).some((key) => {
|
||||
const camelKey = toCamelCase(key);
|
||||
return sanitizedData[key] !== (user.$attributes as { [key: string]: any })[camelKey];
|
||||
});
|
||||
|
||||
let hasAvatarChanged = false;
|
||||
const avatar = request.file('avatar');
|
||||
if (avatar) {
|
||||
const fileHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(avatar.clientName + avatar.size)
|
||||
.digest('hex');
|
||||
const fileName = `avatar-${fileHash}.${avatar.extname}`;
|
||||
const avatarFullPath = path.join('/uploads', `${user.login}`, fileName);
|
||||
|
||||
if (user.avatar != avatarFullPath) {
|
||||
if (user.avatar) {
|
||||
await drive.use('public').delete(user.avatar);
|
||||
}
|
||||
hasAvatarChanged = user.avatar !== avatarFullPath;
|
||||
await avatar.moveToDisk(avatarFullPath, 'public', {
|
||||
name: fileName,
|
||||
overwrite: true, // overwrite in case of conflict
|
||||
disk: 'public',
|
||||
});
|
||||
sanitizedData.avatar = avatarFullPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInputChanges && !hasAvatarChanged) {
|
||||
session.flash('message', 'No changes were made.');
|
||||
return response.redirect().back();
|
||||
}
|
||||
|
||||
await user.merge(sanitizedData).save();
|
||||
session.flash('message', 'User has been updated successfully');
|
||||
return response.redirect().toRoute('settings.profile.edit');
|
||||
} catch (error) {
|
||||
logger.error('Profile update failed:', error);
|
||||
// session.flash('errors', 'Profile update failed. Please try again.');
|
||||
// return response.redirect().back();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async passwordUpdate({ auth, request, response, session }: HttpContext) {
|
||||
// const passwordSchema = schema.create({
|
||||
// old_password: schema.string({ trim: true }, [rules.required()]),
|
||||
// new_password: schema.string({ trim: true }, [rules.minLength(8), rules.maxLength(255), rules.confirmed('confirm_password')]),
|
||||
// confirm_password: schema.string({ trim: true }, [rules.required()]),
|
||||
// });
|
||||
const passwordSchema = vine.object({
|
||||
// first step
|
||||
old_password: vine.string().trim(),
|
||||
// .regex(/^[a-zA-Z0-9]+$/),
|
||||
new_password: vine.string().confirmed({ confirmationField: 'confirm_password' }).trim().minLength(8).maxLength(255),
|
||||
});
|
||||
try {
|
||||
// await request.validate({ schema: passwordSchema });
|
||||
const validator = vine.compile(passwordSchema);
|
||||
await request.validateUsing(validator);
|
||||
} catch (error) {
|
||||
// return response.badRequest(error.messages);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = (await auth.user) as User;
|
||||
const { old_password, new_password } = request.only(['old_password', 'new_password']);
|
||||
|
||||
// if (!(old_password && new_password && confirm_password)) {
|
||||
// return response.status(400).send({ warning: 'Old password and new password are required.' });
|
||||
// }
|
||||
|
||||
// Verify if the provided old password matches the user's current password
|
||||
const isSame = await hash.verify(user.password, old_password);
|
||||
if (!isSame) {
|
||||
session.flash('warning', 'Old password is incorrect.');
|
||||
return response.redirect().back();
|
||||
// return response.flash('warning', 'Old password is incorrect.').redirect().back();
|
||||
}
|
||||
|
||||
// Hash the new password before updating the user's password
|
||||
user.password = new_password;
|
||||
await user.save();
|
||||
|
||||
// return response.status(200).send({ message: 'Password updated successfully.' });
|
||||
session.flash({ message: 'Password updated successfully.' });
|
||||
return response.redirect().toRoute('settings.profile.edit');
|
||||
} catch (error) {
|
||||
// return response.status(500).send({ message: 'Internal server error.' });
|
||||
return response.flash('warning', `Invalid server state. Internal server error.`).redirect().back();
|
||||
}
|
||||
}
|
||||
|
||||
public async enableTwoFactorAuthentication({ auth, response, session }: HttpContext): Promise<void> {
|
||||
// const user: User | undefined = auth?.user;
|
||||
const user = (await User.find(auth.user?.id)) as User;
|
||||
|
@ -115,7 +283,7 @@ export default class UserController {
|
|||
} else {
|
||||
session.flash('error', 'User not found.');
|
||||
}
|
||||
|
||||
|
||||
return response.redirect().back();
|
||||
// return inertia.render('Auth/AccountInfo', {
|
||||
// // status: {
|
||||
|
|
|
@ -33,7 +33,9 @@ import File from '#models/file';
|
|||
import ClamScan from 'clamscan';
|
||||
// import { ValidationException } from '@adonisjs/validator';
|
||||
// import Drive from '@ioc:Adonis/Core/Drive';
|
||||
import drive from '#services/drive';
|
||||
// import drive from '#services/drive';
|
||||
import drive from '@adonisjs/drive/services/main';
|
||||
import path from 'path';
|
||||
import { Exception } from '@adonisjs/core/exceptions';
|
||||
import { MultipartFile } from '@adonisjs/core/types/bodyparser';
|
||||
import * as crypto from 'crypto';
|
||||
|
@ -532,11 +534,18 @@ export default class DatasetController {
|
|||
const fileName = this.generateFilename(file.extname as string);
|
||||
const mimeType = file.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type
|
||||
const datasetFolder = `files/${dataset.id}`;
|
||||
const datasetFullPath = path.join(`${datasetFolder}`, fileName);
|
||||
// const size = file.size;
|
||||
await file.move(drive.makePath(datasetFolder), {
|
||||
// await file.move(drive.makePath(datasetFolder), {
|
||||
// name: fileName,
|
||||
// overwrite: true, // overwrite in case of conflict
|
||||
// });
|
||||
await file.moveToDisk(datasetFullPath, 'local', {
|
||||
name: fileName,
|
||||
overwrite: true, // overwrite in case of conflict
|
||||
disk: 'local',
|
||||
});
|
||||
|
||||
// save file metadata into db
|
||||
const newFile = new File();
|
||||
newFile.pathName = `${datasetFolder}/${fileName}`;
|
||||
|
@ -1031,10 +1040,16 @@ export default class DatasetController {
|
|||
// move to disk:
|
||||
const fileName = `file-${cuid()}.${fileData.extname}`; //'file-ls0jyb8xbzqtrclufu2z2e0c.pdf'
|
||||
const datasetFolder = `files/${dataset.id}`; // 'files/307'
|
||||
const datasetFullPath = path.join(`${datasetFolder}`, fileName);
|
||||
// await fileData.moveToDisk(datasetFolder, { name: fileName, overwrite: true }, 'local');
|
||||
await fileData.move(drive.makePath(datasetFolder), {
|
||||
// await fileData.move(drive.makePath(datasetFolder), {
|
||||
// name: fileName,
|
||||
// overwrite: true, // overwrite in case of conflict
|
||||
// });
|
||||
await fileData.moveToDisk(datasetFullPath, {
|
||||
name: fileName,
|
||||
overwrite: true, // overwrite in case of conflict
|
||||
driver: 'local',
|
||||
});
|
||||
|
||||
//save to db:
|
||||
|
@ -1161,31 +1176,32 @@ export default class DatasetController {
|
|||
if (validStates.includes(dataset.server_state)) {
|
||||
if (dataset.files && dataset.files.length > 0) {
|
||||
for (const file of dataset.files) {
|
||||
// overwritten delete method also delets file on filespace
|
||||
// overwritten delete method also delets file on filespace and db object
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
const datasetFolder = `files/${params.id}`;
|
||||
const folderExists = await drive.exists(datasetFolder);
|
||||
if (folderExists) {
|
||||
const dirListing = drive.list(datasetFolder);
|
||||
const folderContents = await dirListing.toArray();
|
||||
if (folderContents.length === 0) {
|
||||
await drive.delete(datasetFolder);
|
||||
}
|
||||
// const folderExists = await drive.use('local').exists(datasetFolder);
|
||||
// if (folderExists) {
|
||||
// const dirListing = drive.list(datasetFolder);
|
||||
// const folderContents = await dirListing.toArray();
|
||||
// if (folderContents.length === 0) {
|
||||
// await drive.delete(datasetFolder);
|
||||
// }
|
||||
await drive.use('local').deleteAll(datasetFolder);
|
||||
// delete dataset wirh relation in db
|
||||
await dataset.delete();
|
||||
session.flash({ message: 'You have deleted 1 dataset!' });
|
||||
return response.redirect().toRoute('dataset.list');
|
||||
} else {
|
||||
// session.flash({
|
||||
// warning: `You cannot delete this dataset! Invalid server_state: "${dataset.server_state}"!`,
|
||||
// });
|
||||
return response
|
||||
.flash({ warning: `You cannot delete this dataset! Dataset folder "${datasetFolder}" doesn't exist!` })
|
||||
.redirect()
|
||||
.back();
|
||||
}
|
||||
// } else {
|
||||
// // session.flash({
|
||||
// // warning: `You cannot delete this dataset! Invalid server_state: "${dataset.server_state}"!`,
|
||||
// // });
|
||||
// return response
|
||||
// .flash({ warning: `You cannot delete this dataset! Dataset folder "${datasetFolder}" doesn't exist!` })
|
||||
// .redirect()
|
||||
// .back();
|
||||
// }
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof errors.E_VALIDATION_ERROR) {
|
||||
|
@ -1193,7 +1209,8 @@ export default class DatasetController {
|
|||
throw error;
|
||||
} else if (error instanceof Exception) {
|
||||
// General exception handling
|
||||
return response.flash('errors', error.message).redirect().back();
|
||||
session.flash({ error: error.message});
|
||||
return response.redirect().back();
|
||||
} else {
|
||||
session.flash({ error: 'An error occurred while deleting the dataset.' });
|
||||
return response.redirect().back();
|
||||
|
|
Loading…
Add table
editor.link_modal.header
Reference in a new issue