diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..04c1f50 --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,162 @@ +const express = require('express'); +const db = require('../config/database'); + +const router = express.Router(); + +// 管理员权限中间件 +const requireAdmin = (req, res, next) => { + if (!req.user || !req.user.isAdmin) { + return res.status(403).json({ error: '需要管理员权限' }); + } + next(); +}; + +// 获取系统统计信息 +router.get('/stats', requireAdmin, async (req, res) => { + try { + // 获取用户统计 + const [userStats] = await db.pool.execute(` + SELECT + COUNT(*) as total_users, + SUM(is_admin) as admin_count, + SUM(is_active) as active_users, + COUNT(*) - SUM(is_active) as inactive_users, + COUNT(CASE WHEN last_login IS NOT NULL THEN 1 END) as logged_in_users + FROM users + `); + + // 获取服务器统计 + const [serverStats] = await db.pool.execute(` + SELECT + COUNT(*) as total_servers, + COUNT(DISTINCT user_id) as users_with_servers, + AVG(server_count) as avg_servers_per_user + FROM ( + SELECT user_id, COUNT(*) as server_count + FROM servers + GROUP BY user_id + ) user_server_counts + `); + + // 获取连接统计 + const [connectionStats] = await db.pool.execute(` + SELECT + COUNT(*) as total_connections, + SUM(duration) as total_duration_seconds, + AVG(duration) as avg_duration_seconds, + MAX(start_time) as last_connection_time + FROM connection_logs + `); + + // 获取最近活跃用户 + const [recentUsers] = await db.pool.execute(` + SELECT + u.id, u.email, u.last_login, + COUNT(s.id) as server_count, + COUNT(cl.id) as connection_count + FROM users u + LEFT JOIN servers s ON u.id = s.user_id + LEFT JOIN connection_logs cl ON u.id = cl.user_id + WHERE u.last_login IS NOT NULL + GROUP BY u.id, u.email, u.last_login + ORDER BY u.last_login DESC + LIMIT 10 + `); + + res.json({ + user_stats: userStats[0], + server_stats: serverStats[0], + connection_stats: connectionStats[0], + recent_users: recentUsers + }); + } catch (error) { + console.error('获取系统统计错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 获取系统健康状态 +router.get('/health', requireAdmin, async (req, res) => { + try { + // 检查数据库连接 + const [dbCheck] = await db.pool.execute('SELECT 1 as db_ok'); + + // 检查关键表是否存在 + const [tablesCheck] = await db.pool.execute(` + SELECT + COUNT(*) as users_table_exists, + (SELECT COUNT(*) FROM servers) as servers_count, + (SELECT COUNT(*) FROM connection_logs) as logs_count + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'users' + `); + + // 获取系统信息 + const systemInfo = { + node_version: process.version, + platform: process.platform, + memory_usage: process.memoryUsage(), + uptime: process.uptime(), + environment: process.env.NODE_ENV || 'development' + }; + + const healthStatus = { + status: 'healthy', + timestamp: new Date().toISOString(), + database: dbCheck.length > 0 ? 'connected' : 'disconnected', + tables: tablesCheck[0], + system: systemInfo + }; + + res.json(healthStatus); + } catch (error) { + console.error('系统健康检查错误:', error); + res.status(500).json({ + status: 'unhealthy', + error: '系统健康检查失败', + details: error.message + }); + } +}); + +// 清理过期连接日志 +router.post('/cleanup', requireAdmin, async (req, res) => { + try { + const { days = 30 } = req.body; + + // 清理超过指定天数的连接日志 + const [result] = await db.pool.execute( + 'DELETE FROM connection_logs WHERE start_time < DATE_SUB(NOW(), INTERVAL ? DAY)', + [days] + ); + + res.json({ + message: `已清理 ${result.affectedRows} 条过期连接日志`, + cleaned_rows: result.affectedRows + }); + } catch (error) { + console.error('清理连接日志错误:', error); + res.status(500).json({ error: '清理连接日志失败' }); + } +}); + +// 获取系统设置(预留接口) +router.get('/settings', requireAdmin, async (req, res) => { + try { + // 这里可以返回系统配置信息 + res.json({ + settings: { + max_servers_per_user: 100, + max_connections_per_user: 10, + session_timeout_minutes: 60, + allow_registration: true, + require_email_verification: false + } + }); + } catch (error) { + console.error('获取系统设置错误:', error); + res.status(500).json({ error: '获取系统设置失败' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 5e8236a..98ad372 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -82,15 +82,30 @@ router.post('/login', async (req, res) => { const user = users[0]; + // 检查用户是否激活 + if (user.is_active !== 1) { + return res.status(403).json({ error: '账户已被禁用,请联系管理员' }); + } + // 验证密码 const isValidPassword = await bcrypt.compare(password, user.password_hash); if (!isValidPassword) { return res.status(401).json({ error: '邮箱或密码错误' }); } - // 生成JWT令牌 + // 更新最后登录时间 + await db.pool.execute( + 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', + [user.id] + ); + + // 生成JWT令牌(包含管理员信息) const token = jwt.sign( - { userId: user.id, email: user.email }, + { + userId: user.id, + email: user.email, + isAdmin: user.is_admin === 1 + }, process.env.JWT_SECRET || 'webssh-secret-key', { expiresIn: '7d' } ); @@ -101,7 +116,8 @@ router.post('/login', async (req, res) => { user: { id: user.id, email: user.email, - phone: user.phone + phone: user.phone, + is_admin: user.is_admin === 1 } }); } catch (error) { @@ -132,7 +148,7 @@ const authenticateToken = (req, res, next) => { router.get('/me', authenticateToken, async (req, res) => { try { const [users] = await db.pool.execute( - 'SELECT id, email, phone, created_at FROM users WHERE id = ?', + 'SELECT id, email, phone, is_admin, is_active, last_login, created_at FROM users WHERE id = ?', [req.user.userId] ); @@ -140,7 +156,18 @@ router.get('/me', authenticateToken, async (req, res) => { return res.status(404).json({ error: '用户不存在' }); } - res.json({ user: users[0] }); + const user = users[0]; + res.json({ + user: { + id: user.id, + email: user.email, + phone: user.phone, + is_admin: user.is_admin === 1, + is_active: user.is_active === 1, + last_login: user.last_login, + created_at: user.created_at + } + }); } catch (error) { console.error('获取用户信息错误:', error); res.status(500).json({ error: '服务器内部错误' }); diff --git a/backend/routes/users.js b/backend/routes/users.js index 72b0c36..c254e0d 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,71 +1,33 @@ const express = require('express'); -const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const db = require('../config/database'); +const { authenticateToken } = require('../middleware/auth'); const router = express.Router(); -// 验证令牌中间件 -const authenticateToken = (req, res, next) => { - const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; +// 所有路由都需要认证 +router.use(authenticateToken); - if (!token) { - return res.status(401).json({ error: '访问令牌缺失' }); +// 管理员权限中间件 +const requireAdmin = (req, res, next) => { + if (!req.user || !req.user.isAdmin) { + return res.status(403).json({ error: '需要管理员权限' }); } - - jwt.verify(token, process.env.JWT_SECRET || 'webssh-secret-key', (err, user) => { - if (err) { - return res.status(403).json({ error: '令牌无效' }); - } - req.user = user; - next(); - }); + next(); }; -// 检查是否是管理员 -const requireAdmin = async (req, res, next) => { - try { - const [users] = await db.pool.execute( - 'SELECT role FROM users WHERE id = ?', - [req.user.userId] - ); - - if (users.length === 0) { - return res.status(404).json({ error: '用户不存在' }); - } - - if (users[0].role !== 'admin') { - return res.status(403).json({ error: '需要管理员权限' }); - } - - next(); - } catch (error) { - console.error('检查管理员权限错误:', error); - res.status(500).json({ error: '服务器内部错误' }); - } -}; - -// 获取所有用户列表(仅管理员) -router.get('/', authenticateToken, requireAdmin, async (req, res) => { +// 获取所有用户列表(管理员) +router.get('/', requireAdmin, async (req, res) => { try { const [users] = await db.pool.execute(` SELECT - id, email, phone, role, status, - created_at, updated_at + id, email, phone, is_admin, is_active, + last_login, created_at, updated_at, + (SELECT COUNT(*) FROM servers WHERE user_id = users.id) as server_count FROM users ORDER BY created_at DESC `); - // 获取每个用户的服务器数量 - for (let user of users) { - const [serverCount] = await db.pool.execute( - 'SELECT COUNT(*) as count FROM servers WHERE user_id = ?', - [user.id] - ); - user.serverCount = serverCount[0].count; - } - res.json({ users }); } catch (error) { console.error('获取用户列表错误:', error); @@ -73,10 +35,61 @@ router.get('/', authenticateToken, requireAdmin, async (req, res) => { } }); -// 创建新用户(仅管理员) -router.post('/', authenticateToken, requireAdmin, async (req, res) => { +// 获取单个用户详细信息(管理员) +router.get('/:id', requireAdmin, async (req, res) => { try { - const { email, phone, password, role = 'user' } = req.body; + const { id } = req.params; + + // 获取用户基本信息 + const [users] = await db.pool.execute( + 'SELECT id, email, phone, is_admin, is_active, last_login, created_at, updated_at FROM users WHERE id = ?', + [id] + ); + + if (users.length === 0) { + return res.status(404).json({ error: '用户不存在' }); + } + + const user = users[0]; + + // 获取用户的服务器列表 + const [servers] = await db.pool.execute(` + SELECT + id, name, host, port, username, auth_type, + group_name, created_at, updated_at + FROM servers + WHERE user_id = ? + ORDER BY created_at DESC + `, [id]); + + // 获取用户的连接日志统计 + const [connectionStats] = await db.pool.execute(` + SELECT + COUNT(*) as total_connections, + SUM(duration) as total_duration, + MAX(start_time) as last_connection + FROM connection_logs + WHERE user_id = ? + `, [id]); + + res.json({ + user: { + ...user, + server_count: servers.length, + connection_stats: connectionStats[0] + }, + servers + }); + } catch (error) { + console.error('获取用户详情错误:', error); + res.status(500).json({ error: '服务器内部错误' }); + } +}); + +// 创建新用户(管理员) +router.post('/', requireAdmin, async (req, res) => { + try { + const { email, phone, password, is_admin = false } = req.body; // 验证输入 if (!email || !password) { @@ -87,10 +100,6 @@ router.post('/', authenticateToken, requireAdmin, async (req, res) => { return res.status(400).json({ error: '密码长度至少6位' }); } - if (!['admin', 'user'].includes(role)) { - return res.status(400).json({ error: '角色参数无效' }); - } - // 检查用户是否已存在 const [existingUsers] = await db.pool.execute( 'SELECT id FROM users WHERE email = ?', @@ -107,21 +116,17 @@ router.post('/', authenticateToken, requireAdmin, async (req, res) => { // 创建用户 const [result] = await db.pool.execute( - 'INSERT INTO users (email, phone, password_hash, role, status) VALUES (?, ?, ?, ?, ?)', - [email, phone || null, passwordHash, role, 'active'] - ); - - // 获取新创建的用户详情 - const [newUser] = await db.pool.execute( - 'SELECT id, email, phone, role, status, created_at FROM users WHERE id = ?', - [result.insertId] + 'INSERT INTO users (email, phone, password_hash, is_admin) VALUES (?, ?, ?, ?)', + [email, phone || null, passwordHash, is_admin ? 1 : 0] ); res.status(201).json({ message: '用户创建成功', user: { - ...newUser[0], - serverCount: 0 + id: result.insertId, + email, + phone, + is_admin: is_admin ? 1 : 0 } }); } catch (error) { @@ -130,108 +135,69 @@ router.post('/', authenticateToken, requireAdmin, async (req, res) => { } }); -// 更新用户信息(仅管理员) -router.put('/:id', authenticateToken, requireAdmin, async (req, res) => { +// 更新用户信息(管理员) +router.put('/:id', requireAdmin, async (req, res) => { try { - const userId = req.params.id; - const { email, phone, role, status } = req.body; + const { id } = req.params; + const { email, phone, is_admin, is_active } = req.body; // 检查用户是否存在 - const [existingUsers] = await db.pool.execute( + const [users] = await db.pool.execute( 'SELECT id FROM users WHERE id = ?', - [userId] + [id] ); - if (existingUsers.length === 0) { + if (users.length === 0) { return res.status(404).json({ error: '用户不存在' }); } - // 验证角色参数 - if (role && !['admin', 'user'].includes(role)) { - return res.status(400).json({ error: '角色参数无效' }); - } - - // 验证状态参数 - if (status && !['active', 'disabled'].includes(status)) { - return res.status(400).json({ error: '状态参数无效' }); - } - - // 检查邮箱是否已被其他用户使用 - if (email) { - const [duplicateUsers] = await db.pool.execute( - 'SELECT id FROM users WHERE email = ? AND id != ?', - [email, userId] - ); - - if (duplicateUsers.length > 0) { - return res.status(400).json({ error: '该邮箱已被其他用户使用' }); - } - } - // 构建更新字段 const updateFields = []; const updateValues = []; - if (email) { + if (email !== undefined) { updateFields.push('email = ?'); updateValues.push(email); } if (phone !== undefined) { updateFields.push('phone = ?'); - updateValues.push(phone || null); + updateValues.push(phone); } - if (role) { - updateFields.push('role = ?'); - updateValues.push(role); + if (is_admin !== undefined) { + updateFields.push('is_admin = ?'); + updateValues.push(is_admin ? 1 : 0); } - if (status) { - updateFields.push('status = ?'); - updateValues.push(status); + if (is_active !== undefined) { + updateFields.push('is_active = ?'); + updateValues.push(is_active ? 1 : 0); } if (updateFields.length === 0) { - return res.status(400).json({ error: '没有提供更新信息' }); + return res.status(400).json({ error: '没有需要更新的字段' }); } - updateValues.push(userId); + updateValues.push(id); + // 更新用户信息 await db.pool.execute( `UPDATE users SET ${updateFields.join(', ')} WHERE id = ?`, updateValues ); - // 获取更新后的用户详情 - const [updatedUser] = await db.pool.execute( - 'SELECT id, email, phone, role, status, created_at FROM users WHERE id = ?', - [userId] - ); - - // 获取服务器数量 - const [serverCount] = await db.pool.execute( - 'SELECT COUNT(*) as count FROM servers WHERE user_id = ?', - [userId] - ); - - res.json({ - message: '用户信息更新成功', - user: { - ...updatedUser[0], - serverCount: serverCount[0].count - } - }); + res.json({ message: '用户信息更新成功' }); } catch (error) { console.error('更新用户信息错误:', error); res.status(500).json({ error: '服务器内部错误' }); } }); -// 重置用户密码(仅管理员) -router.put('/:id/password', authenticateToken, requireAdmin, async (req, res) => { +// 重置用户密码(管理员) +router.put('/:id/password', requireAdmin, async (req, res) => { try { - const userId = req.params.id; + const { id } = req.params; const { newPassword } = req.body; if (!newPassword) { @@ -239,16 +205,16 @@ router.put('/:id/password', authenticateToken, requireAdmin, async (req, res) => } if (newPassword.length < 6) { - return res.status(400).json({ error: '新密码长度至少6位' }); + return res.status(400).json({ error: '密码长度至少6位' }); } // 检查用户是否存在 - const [existingUsers] = await db.pool.execute( + const [users] = await db.pool.execute( 'SELECT id FROM users WHERE id = ?', - [userId] + [id] ); - if (existingUsers.length === 0) { + if (users.length === 0) { return res.status(404).json({ error: '用户不存在' }); } @@ -259,74 +225,42 @@ router.put('/:id/password', authenticateToken, requireAdmin, async (req, res) => // 更新密码 await db.pool.execute( 'UPDATE users SET password_hash = ? WHERE id = ?', - [newPasswordHash, userId] + [newPasswordHash, id] ); res.json({ message: '密码重置成功' }); } catch (error) { - console.error('重置用户密码错误:', error); + console.error('重置密码错误:', error); res.status(500).json({ error: '服务器内部错误' }); } }); -// 删除用户(仅管理员) -router.delete('/:id', authenticateToken, requireAdmin, async (req, res) => { +// 删除用户(管理员) +router.delete('/:id', requireAdmin, async (req, res) => { try { - const userId = req.params.id; + const { id } = req.params; // 检查用户是否存在 - const [existingUsers] = await db.pool.execute( - 'SELECT id, role FROM users WHERE id = ?', - [userId] - ); - - if (existingUsers.length === 0) { - return res.status(404).json({ error: '用户不存在' }); - } - - // 不能删除自己 - if (parseInt(userId) === parseInt(req.user.userId)) { - return res.status(400).json({ error: '不能删除自己的账户' }); - } - - // 获取用户的服务器数量 - const [serverCount] = await db.pool.execute( - 'SELECT COUNT(*) as count FROM servers WHERE user_id = ?', - [userId] - ); - - const count = serverCount[0].count; - - // 删除用户(级联删除相关数据) - await db.pool.execute( - 'DELETE FROM users WHERE id = ?', - [userId] - ); - - res.json({ - message: `用户删除成功,同时删除了 ${count} 个相关服务器` - }); - } catch (error) { - console.error('删除用户错误:', error); - res.status(500).json({ error: '服务器内部错误' }); - } -}); - -// 获取当前用户信息(包含角色和状态) -router.get('/me', authenticateToken, async (req, res) => { - try { const [users] = await db.pool.execute( - 'SELECT id, email, phone, role, status, created_at FROM users WHERE id = ?', - [req.user.userId] + 'SELECT id FROM users WHERE id = ?', + [id] ); if (users.length === 0) { return res.status(404).json({ error: '用户不存在' }); } - res.json({ user: users[0] }); + // 检查是否为管理员自身(不允许删除自己) + if (parseInt(id) === parseInt(req.user.userId)) { + return res.status(400).json({ error: '不能删除自己的账户' }); + } + + // 删除用户(由于外键约束,关联的服务器和分组也会被删除) + await db.pool.execute('DELETE FROM users WHERE id = ?', [id]); + + res.json({ message: '用户删除成功' }); } catch (error) { - console.error('获取用户信息错误:', error); + console.error('删除用户错误:', error); res.status(500).json({ error: '服务器内部错误' }); } }); diff --git a/backend/server.js b/backend/server.js index 5344eec..53235cc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,6 +9,8 @@ const path = require('path'); const authRoutes = require('./routes/auth'); const serverRoutes = require('./routes/servers'); const sftpRoutes = require('./routes/sftp'); +const userRoutes = require('./routes/users'); +const adminRoutes = require('./routes/admin'); // Database const db = require('./config/database'); @@ -37,14 +39,20 @@ app.use(express.static(path.join(__dirname, '../frontend/dist'))); app.use('/api/auth', authRoutes); app.use('/api/servers', serverRoutes); app.use('/api/sftp', sftpRoutes); +app.use('/api/users', userRoutes); +app.use('/api/admin', adminRoutes); // Socket.io connection handling socketHandler(io); // Serve frontend for production -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); -}); +if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, '../frontend/dist'))); + + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); + }); +} // Database connection test db.authenticate() diff --git a/frontend/src/main.js b/frontend/src/main.js index 04e836a..5c245de 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -38,8 +38,14 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } -app.use(createPinia()) +const pinia = createPinia() +app.use(pinia) app.use(router) app.use(ElementPlus) -app.mount('#app') \ No newline at end of file +// 初始化认证状态 +import { useAuthStore } from '@/stores/auth' +const authStore = useAuthStore() +authStore.initializeAuth().then(() => { + app.mount('#app') +}) \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 398bc48..b25d4c7 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -43,6 +43,12 @@ const routes = [ name: 'GroupManagement', component: () => import('@/views/GroupManagement.vue'), meta: { requiresAuth: true } + }, + { + path: '/user-management', + name: 'UserManagement', + component: () => import('@/views/UserManagement.vue'), + meta: { requiresAuth: true, requiresAdmin: true } } ] @@ -60,6 +66,9 @@ router.beforeEach((to, from, next) => { next('/login') } else if (to.meta.requiresGuest && authStore.isAuthenticated) { next('/') + } else if (to.meta.requiresAdmin && (!authStore.isAuthenticated || !authStore.user?.is_admin)) { + // 检查管理员权限 + next('/') } else { next() } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..328cdde --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,121 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useAuthStore } from '../stores/auth.js' + +// 创建axios实例 +const api = axios.create({ + baseURL: '/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器 - 添加认证token +api.interceptors.request.use( + (config) => { + const authStore = useAuthStore() + if (authStore.token) { + config.headers.Authorization = `Bearer ${authStore.token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 - 统一错误处理 +api.interceptors.response.use( + (response) => { + return response.data + }, + (error) => { + const authStore = useAuthStore() + + if (error.response) { + const { status, data } = error.response + + switch (status) { + case 401: + ElMessage.error('认证失败,请重新登录') + authStore.logout() + window.location.href = '/login' + break + case 403: + ElMessage.error('权限不足,无法访问此资源') + break + case 404: + ElMessage.error('请求的资源不存在') + break + case 500: + ElMessage.error('服务器内部错误,请稍后重试') + break + default: + ElMessage.error(data.error || '请求失败,请稍后重试') + } + } else if (error.request) { + ElMessage.error('网络连接失败,请检查网络设置') + } else { + ElMessage.error('请求配置错误') + } + + return Promise.reject(error) + } +) + +// 用户管理API +export const userAPI = { + // 获取用户列表 + getUsers: (params = {}) => api.get('/users', { params }), + + // 获取用户详情 + getUser: (id) => api.get(`/users/${id}`), + + // 创建用户 + createUser: (userData) => api.post('/users', userData), + + // 更新用户 + updateUser: (id, userData) => api.put(`/users/${id}`, userData), + + // 重置用户密码 + resetPassword: (id, password) => api.put(`/users/${id}/password`, { password }), + + // 删除用户 + deleteUser: (id) => api.delete(`/users/${id}`) +} + +// 系统管理API +export const adminAPI = { + // 获取系统统计 + getStats: () => api.get('/admin/stats'), + + // 系统健康检查 + getHealth: () => api.get('/admin/health'), + + // 清理连接日志 + cleanupLogs: (days) => api.post('/admin/cleanup', { days }), + + // 获取系统设置 + getSettings: () => api.get('/admin/settings'), + + // 更新系统设置 + updateSettings: (settings) => api.put('/admin/settings', settings) +} + +// 服务器管理API +export const serverAPI = { + // 获取服务器列表 + getServers: () => api.get('/servers'), + + // 创建服务器 + createServer: (serverData) => api.post('/servers', serverData), + + // 更新服务器 + updateServer: (id, serverData) => api.put(`/servers/${id}`, serverData), + + // 删除服务器 + deleteServer: (id) => api.delete(`/servers/${id}`) +} + +export default api \ No newline at end of file diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 0bab8b4..57b98e1 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -7,6 +7,17 @@