wa-dev-tools/lib/admin.js
treamz 4008155abd Admin: password confirmation field + validation
- Added password_confirm field in user edit/new forms
- Validation: passwords must match, min 6 chars
- New user: password required
- Edit user: password optional (leave empty to keep)
2026-03-21 23:56:42 +03:00

222 lines
8.6 KiB
JavaScript

/**
* AdminJS setup — ESM modules loaded via dynamic import.
* Exports async function that mounts admin panel on the Express app.
*/
const { Sequelize, DataTypes } = require('sequelize');
// Sequelize connection (reuses same DB as lib/db.js)
const sequelize = new Sequelize(
process.env.DB_NAME || 'wa_tools',
process.env.DB_USER || 'wa_tools',
process.env.DB_PASSWORD || '',
{
host: process.env.DB_HOST || 'localhost',
dialect: 'mariadb',
logging: false,
}
);
// Define models
const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
password_hash: { type: DataTypes.STRING(255), allowNull: false },
display_name: { type: DataTypes.STRING(100) },
role: { type: DataTypes.ENUM('user', 'admin'), defaultValue: 'user' },
is_blocked: { type: DataTypes.BOOLEAN, defaultValue: false },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
last_login: { type: DataTypes.DATE },
}, { tableName: 'users', timestamps: false });
const Setting = sequelize.define('Setting', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
setting_key: { type: DataTypes.STRING(100), allowNull: false, unique: true },
setting_value: { type: DataTypes.TEXT },
description: { type: DataTypes.STRING(255) },
updated_at: { type: DataTypes.DATE },
}, { tableName: 'settings', timestamps: false });
const Category = sequelize.define('Category', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
slug: { type: DataTypes.STRING(50), allowNull: false, unique: true },
title: { type: DataTypes.STRING(100), allowNull: false },
description: { type: DataTypes.STRING(255) },
icon_svg: { type: DataTypes.TEXT },
color: { type: DataTypes.STRING(20), defaultValue: '#0054e6' },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
}, { tableName: 'categories', timestamps: false });
const Tool = sequelize.define('Tool', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
slug: { type: DataTypes.STRING(50), allowNull: false, unique: true },
title: { type: DataTypes.STRING(100), allowNull: false },
description: { type: DataTypes.STRING(255) },
path: { type: DataTypes.STRING(100), allowNull: false },
icon_svg: { type: DataTypes.TEXT },
category_id: { type: DataTypes.INTEGER },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
is_enabled: { type: DataTypes.BOOLEAN, defaultValue: true },
}, { tableName: 'tools', timestamps: false });
const ContentBlock = sequelize.define('ContentBlock', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
block_key: { type: DataTypes.STRING(100), allowNull: false, unique: true },
title: { type: DataTypes.STRING(255) },
body: { type: DataTypes.TEXT },
section: { type: DataTypes.STRING(50) },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
is_visible: { type: DataTypes.BOOLEAN, defaultValue: true },
updated_at: { type: DataTypes.DATE },
}, { tableName: 'content_blocks', timestamps: false });
Tool.belongsTo(Category, { foreignKey: 'category_id' });
Category.hasMany(Tool, { foreignKey: 'category_id' });
/**
* Mount AdminJS on the Express app at /admin
*/
async function setupAdmin(app) {
const AdminJS = (await import('adminjs')).default;
const AdminJSExpress = (await import('@adminjs/express')).default;
const AdminJSSequelize = (await import('@adminjs/sequelize')).default;
AdminJS.registerAdapter({
Resource: AdminJSSequelize.Resource,
Database: AdminJSSequelize.Database,
});
const adminJs = new AdminJS({
rootPath: '/admin',
loginPath: '/admin/login',
logoutPath: '/admin/logout',
branding: {
companyName: 'WA Dev Tools',
logo: false,
softwareBrothers: false,
},
resources: [
{
resource: User,
options: {
navigation: { name: 'Пользователи', icon: 'User' },
listProperties: ['id', 'email', 'display_name', 'role', 'is_blocked', 'last_login'],
editProperties: ['email', 'display_name', 'role', 'is_blocked', 'password', 'password_confirm'],
showProperties: ['id', 'email', 'display_name', 'role', 'is_blocked', 'created_at', 'last_login'],
properties: {
password_hash: { isVisible: false },
password: {
type: 'password',
isVisible: { list: false, show: false, edit: true, filter: false },
description: 'Оставьте пустым чтобы не менять',
},
password_confirm: {
type: 'password',
isVisible: { list: false, show: false, edit: true, filter: false },
description: 'Повторите пароль',
},
role: { availableValues: [{ value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }] },
},
actions: {
edit: {
before: async (request) => {
const pw = request.payload?.password?.trim();
const pw2 = request.payload?.password_confirm?.trim();
if (pw) {
if (pw !== pw2) throw new Error('Пароли не совпадают');
if (pw.length < 6) throw new Error('Пароль минимум 6 символов');
const bcrypt = require('bcrypt');
request.payload.password_hash = await bcrypt.hash(pw, 10);
}
delete request.payload.password;
delete request.payload.password_confirm;
return request;
},
},
new: {
before: async (request) => {
const pw = request.payload?.password?.trim();
const pw2 = request.payload?.password_confirm?.trim();
if (!pw) throw new Error('Пароль обязателен');
if (pw !== pw2) throw new Error('Пароли не совпадают');
if (pw.length < 6) throw new Error('Пароль минимум 6 символов');
const bcrypt = require('bcrypt');
request.payload.password_hash = await bcrypt.hash(pw, 10);
delete request.payload.password;
delete request.payload.password_confirm;
return request;
},
},
},
},
},
{
resource: Category,
options: {
navigation: { name: 'Контент', icon: 'Grid' },
listProperties: ['id', 'slug', 'title', 'color', 'sort_order'],
sort: { sortBy: 'sort_order', direction: 'asc' },
},
},
{
resource: Tool,
options: {
navigation: { name: 'Контент', icon: 'Grid' },
listProperties: ['id', 'slug', 'title', 'path', 'category_id', 'is_enabled', 'sort_order'],
sort: { sortBy: 'sort_order', direction: 'asc' },
properties: {
icon_svg: { type: 'textarea' },
},
},
},
{
resource: ContentBlock,
options: {
navigation: { name: 'Контент', icon: 'Edit' },
listProperties: ['id', 'block_key', 'title', 'section', 'is_visible', 'sort_order'],
properties: {
body: { type: 'richtext' },
},
},
},
{
resource: Setting,
options: {
navigation: { name: 'Настройки', icon: 'Settings' },
listProperties: ['id', 'setting_key', 'setting_value', 'description'],
editProperties: ['setting_value', 'description'],
properties: {
setting_key: { isDisabled: true },
},
},
},
],
});
// Auth: only admin users
const bcrypt = require('bcrypt');
const db = require('./db');
const adminRouter = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
authenticate: async (email, password) => {
const user = await db.findUserByEmail(email);
if (!user || user.role !== 'admin') return null;
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return null;
return { email: user.email, role: user.role, id: user.id };
},
cookieName: 'adminjs',
cookiePassword: process.env.SESSION_SECRET || 'adminjs-secret-change-me',
}, null, {
resave: false,
saveUninitialized: false,
secret: process.env.SESSION_SECRET || 'adminjs-secret-change-me',
});
app.use(adminJs.options.rootPath, adminRouter);
return adminJs;
}
module.exports = { setupAdmin, sequelize, User, Setting, Category, Tool, ContentBlock };