diff --git a/backend/migrations/20251012140844-create-activity-logs.js b/backend/migrations/20251012140844-create-activity-logs.js new file mode 100644 index 0000000..b6764d5 --- /dev/null +++ b/backend/migrations/20251012140844-create-activity-logs.js @@ -0,0 +1,50 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("ActivityLogs", { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: "Users", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + action: { + type: Sequelize.STRING, + allowNull: false, + }, + details: { + type: Sequelize.TEXT, + allowNull: true, + }, + timestamp: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("ActivityLogs"); + }, +}; diff --git a/backend/models/activitylog.js b/backend/models/activitylog.js new file mode 100644 index 0000000..f571fde --- /dev/null +++ b/backend/models/activitylog.js @@ -0,0 +1,44 @@ +"use strict"; +const { Model } = require("sequelize"); +module.exports = (sequelize, DataTypes) => { + class ActivityLog extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + this.belongsTo(models.User, { foreignKey: "userId" }); + } + } + ActivityLog.init( + { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "Users", + key: "id", + }, + }, + action: { + type: DataTypes.STRING, + allowNull: false, + }, + details: { + type: DataTypes.TEXT, + allowNull: true, + }, + timestamp: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + modelName: "ActivityLog", + } + ); + return ActivityLog; +}; diff --git a/backend/models/user.js b/backend/models/user.js index e575471..33d1de0 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -10,6 +10,7 @@ module.exports = (sequelize, DataTypes) => { */ static associate(models) { this.hasMany(models.TimeEntry, { foreignKey: "userId" }); + this.hasMany(models.ActivityLog, { foreignKey: "userId" }); } // Instance method to check password diff --git a/backend/routes/activityLogs.js b/backend/routes/activityLogs.js new file mode 100644 index 0000000..2e34b3c --- /dev/null +++ b/backend/routes/activityLogs.js @@ -0,0 +1,72 @@ +const express = require("express"); +const { ActivityLog, User } = require("../models"); +const { authenticate } = require("../middleware/auth"); + +const router = express.Router(); + +// Get all activity logs (admin only) +router.get("/", authenticate, async (req, res) => { + try { + if (req.user.role !== "admin") { + return res.status(403).json({ error: "Access denied" }); + } + + const logs = await ActivityLog.findAll({ + include: [ + { + model: User, + attributes: ["id", "username", "role"], + }, + ], + order: [["timestamp", "DESC"]], + }); + + res.json(logs); + } catch (error) { + console.error("Error fetching activity logs:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Get activity logs for managers and users (manager only) +router.get("/managed", authenticate, async (req, res) => { + try { + if (req.user.role !== "manager" && req.user.role !== "admin") { + return res.status(403).json({ error: "Access denied" }); + } + + const logs = await ActivityLog.findAll({ + include: [ + { + model: User, + attributes: ["id", "username", "role"], + }, + ], + where: { + "$User.role$": ["manager", "user"], + }, + order: [["timestamp", "DESC"]], + }); + + res.json(logs); + } catch (error) { + console.error("Error fetching managed activity logs:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Log an activity (internal function) +const logActivity = async (userId, action, details = null) => { + try { + await ActivityLog.create({ + userId, + action, + details, + timestamp: new Date(), + }); + } catch (error) { + console.error("Error logging activity:", error); + } +}; + +module.exports = { router, logActivity }; diff --git a/backend/routes/timeEntries.js b/backend/routes/timeEntries.js index 018f5f2..30ee82e 100644 --- a/backend/routes/timeEntries.js +++ b/backend/routes/timeEntries.js @@ -1,5 +1,6 @@ const express = require("express"); const { TimeEntry, User } = require("../models"); +const { logActivity } = require("./activityLogs"); const { authenticate, authorizeAdmin, @@ -28,6 +29,14 @@ router.get("/all", authenticate, authorizeManager, async (req, res) => { include: [{ model: User, attributes: ["username"] }], order: [["date", "DESC"]], }); + + // Log the export action + await logActivity( + req.user.id, + "Экспорт общей таблицы", + `Просмотрена общая таблица записей времени` + ); + res.json(entries); } catch (error) { res.status(500).json({ message: "Server error" }); @@ -48,6 +57,18 @@ router.get( order: [["date", "DESC"]], }); console.log("Found entries:", entries); + + // Get username for logging + const user = await User.findByPk(userId); + const username = user ? user.username : "Unknown"; + + // Log the export action for specific user + await logActivity( + req.user.id, + "Экспорт таблицы пользователя", + `Просмотрена таблица записей времени для пользователя: ${username}` + ); + res.json(entries); } catch (error) { console.error("Error fetching user entries:", error); @@ -100,6 +121,14 @@ router.delete("/delete-all", authenticate, async (req, res) => { const deletedCount = await TimeEntry.destroy({ where: { userId: req.user.id }, }); + + // Log the delete all action + await logActivity( + req.user.id, + "Удаление всех записей", + `Удалены все записи времени (${deletedCount} записей)` + ); + res.json({ message: "All entries deleted", deletedCount }); } catch (error) { res.status(500).json({ message: "Server error" }); diff --git a/backend/routes/users.js b/backend/routes/users.js index 33b7c48..0235b08 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,5 +1,6 @@ const express = require("express"); const { User } = require("../models"); +const { logActivity } = require("./activityLogs"); const { authenticate, authorizeAdmin, @@ -48,6 +49,14 @@ router.post("/", authenticate, authorizeManager, async (req, res) => { password, role: userRole, }); + + // Log the creation action + await logActivity( + req.user.id, + "Создание пользователя", + `Создан новый пользователь: ${username} с ролью ${userRole}` + ); + res .status(201) .json({ id: user.id, username: user.username, role: user.role }); @@ -73,6 +82,14 @@ router.put( } user.password = password; await user.save(); + + // Log the password reset action + await logActivity( + req.user.id, + "Сброс пароля", + `Сброшен пароль для пользователя: ${user.username}` + ); + res.json({ message: "Password updated" }); } catch (error) { res.status(500).json({ message: "Server error" }); @@ -88,6 +105,14 @@ router.delete("/:id", authenticate, authorizeManager, async (req, res) => { return res.status(404).json({ message: "User not found" }); } await user.destroy(); + + // Log the deletion action + await logActivity( + req.user.id, + "Удаление пользователя", + `Удален пользователь: ${user.username}` + ); + res.json({ message: "User deleted" }); } catch (error) { res.status(500).json({ message: "Server error" }); diff --git a/backend/server.js b/backend/server.js index 4853c1e..e990daa 100644 --- a/backend/server.js +++ b/backend/server.js @@ -82,6 +82,8 @@ sequelize app.use("/api/auth", require("./routes/auth")); app.use("/api/users", require("./routes/users")); app.use("/api/time-entries", require("./routes/timeEntries")); +const { router: activityLogsRouter } = require("./routes/activityLogs"); +app.use("/api/activity-logs", activityLogsRouter); // Health check app.get("/health", (req, res) => res.status(200).json({ status: "OK" })); diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 9e98d12..633d513 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; -import { usersAPI, timeEntriesAPI } from "../services/api"; +import { usersAPI, timeEntriesAPI, activityLogsAPI } from "../services/api"; import UserTimeEntriesModal from "./UserTimeEntriesModal"; import * as XLSX from "xlsx"; @@ -16,6 +16,9 @@ const AdminPanel = () => { const [newPassword, setNewPassword] = useState(""); const [deleteUserId, setDeleteUserId] = useState(null); const [selectedUser, setSelectedUser] = useState(null); + const [sortConfig, setSortConfig] = useState({ key: null, direction: "asc" }); + const [activityLogs, setActivityLogs] = useState([]); + const [showLogs, setShowLogs] = useState(false); useEffect(() => { fetchUsers(); @@ -30,6 +33,15 @@ const AdminPanel = () => { } }; + const fetchActivityLogs = async () => { + try { + const response = await activityLogsAPI.getLogs(); + setActivityLogs(response.data); + } catch (error) { + console.error("Failed to fetch activity logs:", error); + } + }; + const handleExportAll = async () => { try { const response = await timeEntriesAPI.getAllEntries(); @@ -153,6 +165,27 @@ const AdminPanel = () => { } }; + const handleSort = (key) => { + let direction = "asc"; + if (sortConfig.key === key && sortConfig.direction === "asc") { + direction = "desc"; + } + setSortConfig({ key, direction }); + }; + + const sortedUsers = [...users].sort((a, b) => { + if (sortConfig.key === null) return 0; + const aValue = a[sortConfig.key]; + const bValue = b[sortConfig.key]; + if (aValue < bValue) { + return sortConfig.direction === "asc" ? -1 : 1; + } + if (aValue > bValue) { + return sortConfig.direction === "asc" ? 1 : -1; + } + return 0; + }); + return (
| ID | -Имя пользователя | -Роль | +handleSort("id")} + style={{ + cursor: "pointer", + backgroundColor: + sortConfig.key === "id" ? "#f0f0f0" : "inherit", + color: sortConfig.key === "id" ? "#007bff" : "inherit", + }} + title="Кликни для сортировки" + > + ID{" "} + {sortConfig.key === "id" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + {sortConfig.key !== "id" && "↕"} + | +handleSort("username")} + style={{ + cursor: "pointer", + backgroundColor: + sortConfig.key === "username" ? "#f0f0f0" : "inherit", + color: sortConfig.key === "username" ? "#007bff" : "inherit", + }} + title="Кликни для сортировки" + > + Имя пользователя{" "} + {sortConfig.key === "username" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + {sortConfig.key !== "username" && "↕"} + | +handleSort("role")} + style={{ + cursor: "pointer", + backgroundColor: + sortConfig.key === "role" ? "#f0f0f0" : "inherit", + color: sortConfig.key === "role" ? "#007bff" : "inherit", + }} + title="Кликни для сортировки" + > + Роль{" "} + {sortConfig.key === "role" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + {sortConfig.key !== "role" && "↕"} + | Действия | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {user.id} |
@@ -393,6 +477,45 @@ const AdminPanel = () => {
)}
+ {showLogs && (
+
+
+ )}
+
{selectedUser && (
Логи действий+
+
+
Нет записей логов
+ )}
+ Менеджер панель@@ -230,14 +252,56 @@ const ManagerPanel = () => {
|