237 lines
9.4 KiB
JavaScript
237 lines
9.4 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 (pw2 && pw !== pw2) {
|
|
request.payload.errors = request.payload.errors || {};
|
|
request.payload.errors.password_confirm = { message: 'Пароли не совпадают' };
|
|
return request;
|
|
}
|
|
if (pw.length < 6) {
|
|
request.payload.errors = request.payload.errors || {};
|
|
request.payload.errors.password = { message: 'Минимум 6 символов' };
|
|
return request;
|
|
}
|
|
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) {
|
|
request.payload.errors = request.payload.errors || {};
|
|
request.payload.errors.password = { message: 'Пароль обязателен' };
|
|
return request;
|
|
}
|
|
if (pw2 && pw !== pw2) {
|
|
request.payload.errors = request.payload.errors || {};
|
|
request.payload.errors.password_confirm = { message: 'Пароли не совпадают' };
|
|
return request;
|
|
}
|
|
if (pw.length < 6) {
|
|
request.payload.errors = request.payload.errors || {};
|
|
request.payload.errors.password = { message: 'Минимум 6 символов' };
|
|
return request;
|
|
}
|
|
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: use main site session — admin role required
|
|
const adminRouter = AdminJSExpress.buildRouter(adminJs);
|
|
|
|
// Middleware: check main session for admin role before AdminJS
|
|
app.use(adminJs.options.rootPath, (req, res, next) => {
|
|
// Allow AdminJS static assets (js, css, etc.)
|
|
if (req.path.match(/\.(js|css|png|svg|ico|map|woff|woff2|ttf)$/)) return next();
|
|
|
|
if (req.session && req.session.user && req.session.user.role === 'admin') {
|
|
return next();
|
|
}
|
|
|
|
// Not admin — redirect to login with return URL
|
|
req.session.returnTo = '/admin';
|
|
return res.redirect('/auth/login');
|
|
}, adminRouter);
|
|
|
|
return adminJs;
|
|
}
|
|
|
|
module.exports = { setupAdmin, sequelize, User, Setting, Category, Tool, ContentBlock };
|