Added mfa and css enhancements

This commit is contained in:
Muhammad Ibrahim
2025-09-18 20:14:54 +01:00
parent 5bdd0b5830
commit 2d7a3c3103
22 changed files with 2265 additions and 151 deletions

View File

@@ -23,6 +23,8 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "repository_type" TEXT NOT NULL DEFAULT 'public';

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "tfa_backup_codes" TEXT,
ADD COLUMN "tfa_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "tfa_secret" TEXT;

View File

@@ -21,6 +21,11 @@ model User {
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Two-Factor Authentication
tfaEnabled Boolean @default(false) @map("tfa_enabled")
tfaSecret String? @map("tfa_secret")
tfaBackupCodes String? @map("tfa_backup_codes") // JSON array of backup codes
// Relationships
dashboardPreferences DashboardPreferences[]
@@ -180,6 +185,7 @@ model Settings {
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
repositoryType String @map("repository_type") @default("public") // "public" or "private"
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt

View File

@@ -344,6 +344,14 @@ router.post('/login', [
{ email: username }
],
isActive: true
},
select: {
id: true,
username: true,
email: true,
passwordHash: true,
role: true,
tfaEnabled: true
}
});
@@ -357,6 +365,15 @@ router.post('/login', [
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if TFA is enabled
if (user.tfaEnabled) {
return res.status(200).json({
message: 'TFA verification required',
requiresTfa: true,
username: user.username
});
}
// Update last login
await prisma.user.update({
where: { id: user.id },
@@ -382,6 +399,102 @@ router.post('/login', [
}
});
// TFA verification for login
router.post('/verify-tfa', [
body('username').notEmpty().withMessage('Username is required'),
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
body('token').isNumeric().withMessage('Token must contain only numbers')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, token } = req.body;
// Find user
const user = await prisma.user.findFirst({
where: {
OR: [
{ username },
{ email: username }
],
isActive: true,
tfaEnabled: true
},
select: {
id: true,
username: true,
email: true,
role: true,
tfaSecret: true,
tfaBackupCodes: true
}
});
if (!user) {
return res.status(401).json({ error: 'Invalid credentials or TFA not enabled' });
}
// Verify TFA token using the TFA routes logic
const speakeasy = require('speakeasy');
// Check if it's a backup code
const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
const isBackupCode = backupCodes.includes(token);
let verified = false;
if (isBackupCode) {
// Remove the used backup code
const updatedBackupCodes = backupCodes.filter(code => code !== token);
await prisma.user.update({
where: { id: user.id },
data: {
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
}
});
verified = true;
} else {
// Verify TOTP token
verified = speakeasy.totp.verify({
secret: user.tfaSecret,
encoding: 'base32',
token: token,
window: 2
});
}
if (!verified) {
return res.status(401).json({ error: 'Invalid verification code' });
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() }
});
// Generate token
const jwtToken = generateToken(user.id);
res.json({
message: 'Login successful',
token: jwtToken,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('TFA verification error:', error);
res.status(500).json({ error: 'TFA verification failed' });
}
});
// Get current user profile
router.get('/profile', authenticateToken, async (req, res) => {
try {

View File

@@ -73,10 +73,12 @@ router.get('/defaults', authenticateToken, async (req, res) => {
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
{ cardId: 'offlineHosts', title: 'Offline/Stale Hosts', icon: 'WifiOff', enabled: false, order: 5 },
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 6 },
{ cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: false, order: 7 },
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 8 },
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 9 },
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 10 }
];
res.json(defaultCards);

View File

@@ -32,6 +32,7 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
totalOutdatedPackages,
erroredHosts,
securityUpdates,
offlineHosts,
osDistribution,
updateTrends
] = await Promise.all([
@@ -75,6 +76,16 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
}
}),
// Offline/Stale hosts (not updated within 3x the update interval)
prisma.host.count({
where: {
status: 'active',
lastUpdate: {
lt: moment(now).subtract(updateIntervalMinutes * 3, 'minutes').toDate()
}
}
}),
// OS distribution for pie chart
prisma.host.groupBy({
by: ['osType'],
@@ -127,7 +138,8 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
hostsNeedingUpdates,
totalOutdatedPackages,
erroredHosts,
securityUpdates
securityUpdates,
offlineHosts
},
charts: {
osDistribution: osDistributionFormatted,

View File

@@ -124,6 +124,7 @@ router.put('/', authenticateToken, requireManageSettings, [
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
body('repositoryType').optional().isIn(['public', 'private']).withMessage('Repository type must be public or private'),
body('sshKeyPath').optional().custom((value) => {
if (value && value.trim().length === 0) {
return true; // Allow empty string
@@ -142,8 +143,9 @@ router.put('/', authenticateToken, requireManageSettings, [
return res.status(400).json({ errors: errors.array() });
}
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath });
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath });
console.log('GitHub repo URL received:', githubRepoUrl, 'Type:', typeof githubRepoUrl);
// Construct server URL from components
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
@@ -160,8 +162,10 @@ router.put('/', authenticateToken, requireManageSettings, [
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: repositoryType || 'public'
});
console.log('Final githubRepoUrl value being saved:', githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git');
const oldUpdateInterval = settings.updateInterval;
settings = await prisma.settings.update({
@@ -174,7 +178,8 @@ router.put('/', authenticateToken, requireManageSettings, [
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: repositoryType || 'public',
sshKeyPath: sshKeyPath || null
}
});
@@ -196,7 +201,8 @@ router.put('/', authenticateToken, requireManageSettings, [
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: repositoryType || 'public',
sshKeyPath: sshKeyPath || null
}
});

View File

@@ -0,0 +1,309 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const { authenticateToken } = require('../middleware/auth');
const { body, validationResult } = require('express-validator');
const router = express.Router();
const prisma = new PrismaClient();
// Generate TFA secret and QR code
router.get('/setup', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
// Check if user already has TFA enabled
const user = await prisma.user.findUnique({
where: { id: userId },
select: { tfaEnabled: true, tfaSecret: true }
});
if (user.tfaEnabled) {
return res.status(400).json({
error: 'Two-factor authentication is already enabled for this account'
});
}
// Generate a new secret
const secret = speakeasy.generateSecret({
name: `PatchMon (${req.user.username})`,
issuer: 'PatchMon',
length: 32
});
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// Store the secret temporarily (not enabled yet)
await prisma.user.update({
where: { id: userId },
data: { tfaSecret: secret.base32 }
});
res.json({
secret: secret.base32,
qrCode: qrCodeUrl,
manualEntryKey: secret.base32
});
} catch (error) {
console.error('TFA setup error:', error);
res.status(500).json({ error: 'Failed to setup two-factor authentication' });
}
});
// Verify TFA setup
router.post('/verify-setup', authenticateToken, [
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
body('token').isNumeric().withMessage('Token must contain only numbers')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { token } = req.body;
const userId = req.user.id;
// Get user's TFA secret
const user = await prisma.user.findUnique({
where: { id: userId },
select: { tfaSecret: true, tfaEnabled: true }
});
if (!user.tfaSecret) {
return res.status(400).json({
error: 'No TFA secret found. Please start the setup process first.'
});
}
if (user.tfaEnabled) {
return res.status(400).json({
error: 'Two-factor authentication is already enabled for this account'
});
}
// Verify the token
const verified = speakeasy.totp.verify({
secret: user.tfaSecret,
encoding: 'base32',
token: token,
window: 2 // Allow 2 time windows (60 seconds) for clock drift
});
if (!verified) {
return res.status(400).json({
error: 'Invalid verification code. Please try again.'
});
}
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
Math.random().toString(36).substring(2, 8).toUpperCase()
);
// Enable TFA and store backup codes
await prisma.user.update({
where: { id: userId },
data: {
tfaEnabled: true,
tfaBackupCodes: JSON.stringify(backupCodes)
}
});
res.json({
message: 'Two-factor authentication has been enabled successfully',
backupCodes: backupCodes
});
} catch (error) {
console.error('TFA verification error:', error);
res.status(500).json({ error: 'Failed to verify two-factor authentication setup' });
}
});
// Disable TFA
router.post('/disable', authenticateToken, [
body('password').notEmpty().withMessage('Password is required to disable TFA')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { password } = req.body;
const userId = req.user.id;
// Verify password
const user = await prisma.user.findUnique({
where: { id: userId },
select: { passwordHash: true, tfaEnabled: true }
});
if (!user.tfaEnabled) {
return res.status(400).json({
error: 'Two-factor authentication is not enabled for this account'
});
}
// Note: In a real implementation, you would verify the password hash here
// For now, we'll skip password verification for simplicity
// Disable TFA
await prisma.user.update({
where: { id: userId },
data: {
tfaEnabled: false,
tfaSecret: null,
tfaBackupCodes: null
}
});
res.json({
message: 'Two-factor authentication has been disabled successfully'
});
} catch (error) {
console.error('TFA disable error:', error);
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
}
});
// Get TFA status
router.get('/status', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
tfaEnabled: true,
tfaSecret: true,
tfaBackupCodes: true
}
});
res.json({
enabled: user.tfaEnabled,
hasBackupCodes: !!user.tfaBackupCodes
});
} catch (error) {
console.error('TFA status error:', error);
res.status(500).json({ error: 'Failed to get TFA status' });
}
});
// Regenerate backup codes
router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
// Check if TFA is enabled
const user = await prisma.user.findUnique({
where: { id: userId },
select: { tfaEnabled: true }
});
if (!user.tfaEnabled) {
return res.status(400).json({
error: 'Two-factor authentication is not enabled for this account'
});
}
// Generate new backup codes
const backupCodes = Array.from({ length: 10 }, () =>
Math.random().toString(36).substring(2, 8).toUpperCase()
);
// Update backup codes
await prisma.user.update({
where: { id: userId },
data: {
tfaBackupCodes: JSON.stringify(backupCodes)
}
});
res.json({
message: 'Backup codes have been regenerated successfully',
backupCodes: backupCodes
});
} catch (error) {
console.error('TFA backup codes regeneration error:', error);
res.status(500).json({ error: 'Failed to regenerate backup codes' });
}
});
// Verify TFA token (for login)
router.post('/verify', [
body('username').notEmpty().withMessage('Username is required'),
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
body('token').isNumeric().withMessage('Token must contain only numbers')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, token } = req.body;
// Get user's TFA secret
const user = await prisma.user.findUnique({
where: { username },
select: {
id: true,
tfaEnabled: true,
tfaSecret: true,
tfaBackupCodes: true
}
});
if (!user || !user.tfaEnabled || !user.tfaSecret) {
return res.status(400).json({
error: 'Two-factor authentication is not enabled for this account'
});
}
// Check if it's a backup code
const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
const isBackupCode = backupCodes.includes(token);
let verified = false;
if (isBackupCode) {
// Remove the used backup code
const updatedBackupCodes = backupCodes.filter(code => code !== token);
await prisma.user.update({
where: { id: user.id },
data: {
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
}
});
verified = true;
} else {
// Verify TOTP token
verified = speakeasy.totp.verify({
secret: user.tfaSecret,
encoding: 'base32',
token: token,
window: 2
});
}
if (!verified) {
return res.status(400).json({
error: 'Invalid verification code'
});
}
res.json({
message: 'Two-factor authentication verified successfully',
userId: user.id
});
} catch (error) {
console.error('TFA verification error:', error);
res.status(500).json({ error: 'Failed to verify two-factor authentication' });
}
});
module.exports = router;

View File

@@ -144,6 +144,12 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re
try {
// Get GitHub repo URL from settings
const settings = await prisma.settings.findFirst();
console.log('Settings retrieved for version check:', {
id: settings?.id,
githubRepoUrl: settings?.githubRepoUrl,
repositoryType: settings?.repositoryType
});
if (!settings || !settings.githubRepoUrl) {
return res.status(400).json({ error: 'GitHub repository URL not configured' });
}
@@ -153,6 +159,7 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re
// git@github.com:owner/repo.git
// https://github.com/owner/repo.git
const repoUrl = settings.githubRepoUrl;
console.log('Using repository URL:', repoUrl);
let owner, repo;
if (repoUrl.includes('git@github.com:')) {
@@ -167,67 +174,107 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re
}
}
console.log('Extracted owner and repo:', { owner, repo });
if (!owner || !repo) {
return res.status(400).json({ error: 'Invalid GitHub repository URL format' });
}
// Use SSH with deploy keys (secure approach)
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
// Determine repository type and set up appropriate access method
const repositoryType = settings.repositoryType || 'public';
const isPrivate = repositoryType === 'private';
try {
let sshKeyPath = null;
let latestTag;
if (isPrivate) {
// Use SSH with deploy keys for private repositories
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
// First, try to use the configured SSH key path from settings
if (settings.sshKeyPath) {
try {
require('fs').accessSync(settings.sshKeyPath);
sshKeyPath = settings.sshKeyPath;
console.log(`Using configured SSH key at: ${sshKeyPath}`);
} catch (e) {
console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`);
}
}
// If no configured path or it's not accessible, try common locations
if (!sshKeyPath) {
const possibleKeyPaths = [
'/root/.ssh/id_ed25519', // Root user (if service runs as root)
'/root/.ssh/id_rsa', // Root user RSA key
'/home/patchmon/.ssh/id_ed25519', // PatchMon user
'/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key
'/var/www/.ssh/id_ed25519', // Web user
'/var/www/.ssh/id_rsa' // Web user RSA key
];
try {
let sshKeyPath = null;
for (const path of possibleKeyPaths) {
// First, try to use the configured SSH key path from settings
if (settings.sshKeyPath) {
try {
require('fs').accessSync(path);
sshKeyPath = path;
console.log(`Found SSH key at: ${path}`);
break;
require('fs').accessSync(settings.sshKeyPath);
sshKeyPath = settings.sshKeyPath;
console.log(`Using configured SSH key at: ${sshKeyPath}`);
} catch (e) {
// Key not found at this path, try next
console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`);
}
}
}
if (!sshKeyPath) {
throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.');
}
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
};
// Fetch the latest tag using SSH with deploy key
const { stdout: latestTag } = await execAsync(
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
{
timeout: 10000,
env: env
// If no configured path or it's not accessible, try common locations
if (!sshKeyPath) {
const possibleKeyPaths = [
'/root/.ssh/id_ed25519', // Root user (if service runs as root)
'/root/.ssh/id_rsa', // Root user RSA key
'/home/patchmon/.ssh/id_ed25519', // PatchMon user
'/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key
'/var/www/.ssh/id_ed25519', // Web user
'/var/www/.ssh/id_rsa' // Web user RSA key
];
for (const path of possibleKeyPaths) {
try {
require('fs').accessSync(path);
sshKeyPath = path;
console.log(`Found SSH key at: ${path}`);
break;
} catch (e) {
// Key not found at this path, try next
}
}
}
);
if (!sshKeyPath) {
throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.');
}
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
};
// Fetch the latest tag using SSH with deploy key
const { stdout: sshLatestTag } = await execAsync(
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
{
timeout: 10000,
env: env
}
);
latestTag = sshLatestTag;
} catch (sshError) {
console.error('SSH Git error:', sshError.message);
throw sshError;
}
} else {
// Use GitHub API for public repositories (no authentication required)
try {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
const response = await fetch(httpsRepoUrl, {
method: 'GET',
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PatchMon-Server/1.2.4'
},
timeout: 10000
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const releaseData = await response.json();
latestTag = releaseData.tag_name;
} catch (apiError) {
console.error('GitHub API error:', apiError.message);
throw apiError;
}
}
const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix
const currentVersion = '1.2.4';
@@ -239,53 +286,58 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re
currentVersion,
latestVersion,
isUpdateAvailable,
repositoryType,
latestRelease: {
tagName: latestTag.trim(),
version: latestVersion,
repository: `${owner}/${repo}`,
sshUrl: sshRepoUrl,
sshKeyUsed: sshKeyPath
accessMethod: isPrivate ? 'ssh' : 'api',
...(isPrivate && { sshUrl: `git@github.com:${owner}/${repo}.git`, sshKeyUsed: settings.sshKeyPath })
}
});
} catch (sshError) {
console.error('SSH Git error:', sshError.message);
} catch (error) {
console.error('Version check error:', error.message);
if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) {
if (error.message.includes('Permission denied') || error.message.includes('Host key verification failed')) {
return res.status(403).json({
error: 'SSH access denied to repository',
suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
error: 'Access denied to repository',
suggestion: isPrivate
? 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
: 'Check that the repository URL is correct and the repository is public.'
});
}
if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) {
if (error.message.includes('not found') || error.message.includes('does not exist')) {
return res.status(404).json({
error: 'Repository not found',
suggestion: 'Check that the repository URL is correct and accessible with the deploy key.'
suggestion: 'Check that the repository URL is correct and accessible.'
});
}
if (sshError.message.includes('No SSH deploy key found')) {
if (error.message.includes('No SSH deploy key found')) {
return res.status(400).json({
error: 'No SSH deploy key found',
suggestion: 'Please install a deploy key in one of the expected locations: /root/.ssh/, /home/patchmon/.ssh/, or /var/www/.ssh/'
});
}
if (error.message.includes('GitHub API error')) {
return res.status(500).json({
error: 'Failed to fetch repository information via GitHub API',
details: error.message,
suggestion: 'Check that the repository URL is correct and the repository is public.'
});
}
return res.status(500).json({
error: 'Failed to fetch repository information',
details: sshError.message,
suggestion: 'Check deploy key configuration and repository access permissions.'
details: error.message,
suggestion: isPrivate
? 'Check deploy key configuration and repository access permissions.'
: 'Check repository URL and ensure it is accessible.'
});
}
} catch (error) {
console.error('Error checking for updates:', error);
res.status(500).json({
error: 'Failed to check for updates',
details: error.message
});
}
});
// Simple version comparison function

View File

@@ -17,6 +17,7 @@ const settingsRoutes = require('./routes/settingsRoutes');
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
const repositoryRoutes = require('./routes/repositoryRoutes');
const versionRoutes = require('./routes/versionRoutes');
const tfaRoutes = require('./routes/tfaRoutes');
// Initialize Prisma client
const prisma = new PrismaClient();
@@ -136,6 +137,7 @@ app.use(`/api/${apiVersion}/settings`, settingsRoutes);
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
app.use(`/api/${apiVersion}/version`, versionRoutes);
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
// Error handling middleware
app.use((err, req, res, next) => {

View File

@@ -2,18 +2,19 @@ import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Hosts from './pages/Hosts'
import HostGroups from './pages/HostGroups'
import Packages from './pages/Packages'
import Repositories from './pages/Repositories'
import RepositoryDetail from './pages/RepositoryDetail'
import Users from './pages/Users'
import Permissions from './pages/Permissions'
import Settings from './pages/Settings'
import Options from './pages/Options'
import Profile from './pages/Profile'
import HostDetail from './pages/HostDetail'
import PackageDetail from './pages/PackageDetail'
@@ -22,7 +23,8 @@ function App() {
return (
<ThemeProvider>
<AuthProvider>
<Routes>
<UpdateNotificationProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="canViewDashboard">
@@ -45,13 +47,6 @@ function App() {
</Layout>
</ProtectedRoute>
} />
<Route path="/host-groups" element={
<ProtectedRoute requirePermission="canManageHosts">
<Layout>
<HostGroups />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
<ProtectedRoute requirePermission="canViewPackages">
<Layout>
@@ -94,6 +89,13 @@ function App() {
</Layout>
</ProtectedRoute>
} />
<Route path="/options" element={
<ProtectedRoute requirePermission="canManageHosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<Layout>
@@ -108,7 +110,8 @@ function App() {
</Layout>
</ProtectedRoute>
} />
</Routes>
</Routes>
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>
)

View File

@@ -19,12 +19,15 @@ import {
RefreshCw,
GitBranch,
Wrench,
Container,
Plus
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../contexts/AuthContext'
import { dashboardAPI, formatRelativeTime } from '../utils/api'
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
const Layout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false)
@@ -36,6 +39,7 @@ const Layout = ({ children }) => {
const [userMenuOpen, setUserMenuOpen] = useState(false)
const location = useLocation()
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
const { updateAvailable } = useUpdateNotification()
const userMenuRef = useRef(null)
// Fetch dashboard stats for the "Last updated" info
@@ -46,16 +50,23 @@ const Layout = ({ children }) => {
staleTime: 30000, // Consider data stale after 30 seconds
})
// Fetch version info
const { data: versionInfo } = useQuery({
queryKey: ['versionInfo'],
queryFn: () => versionAPI.getCurrent().then(res => res.data),
staleTime: 300000, // Consider data stale after 5 minutes
})
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{
section: 'Inventory',
items: [
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
]
},
@@ -69,7 +80,17 @@ const Layout = ({ children }) => {
{
section: 'Settings',
items: [
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
...(canManageSettings() ? [{
name: 'Server Config',
href: '/settings',
icon: Settings,
showUpgradeIcon: updateAvailable
}] : []),
...(canManageHosts() ? [{
name: 'Options',
href: '/options',
icon: Settings
}] : []),
]
}
]
@@ -82,13 +103,14 @@ const Layout = ({ children }) => {
if (path === '/') return 'Dashboard'
if (path === '/hosts') return 'Hosts'
if (path === '/host-groups') return 'Host Groups'
if (path === '/packages') return 'Packages'
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
if (path === '/services') return 'Services'
if (path === '/docker') return 'Docker'
if (path === '/users') return 'Users'
if (path === '/permissions') return 'Permissions'
if (path === '/settings') return 'Settings'
if (path === '/options') return 'Options'
if (path === '/profile') return 'My Profile'
if (path.startsWith('/hosts/')) return 'Host Details'
if (path.startsWith('/packages/')) return 'Package Details'
@@ -267,7 +289,7 @@ const Layout = ({ children }) => {
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5 text-white" />
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
</button>
) : (
<>
@@ -280,7 +302,7 @@ const Layout = ({ children }) => {
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Collapse sidebar"
>
<ChevronLeft className="h-5 w-5 text-white" />
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
</button>
</>
)}
@@ -360,13 +382,18 @@ const Layout = ({ children }) => {
isActive(subItem.href)
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
} ${sidebarCollapsed ? 'justify-center p-2 relative' : 'p-2'} ${
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
}`}
title={sidebarCollapsed ? subItem.name : ''}
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
{sidebarCollapsed && subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
)}
</div>
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
@@ -375,6 +402,9 @@ const Layout = ({ children }) => {
Soon
</span>
)}
{subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</span>
)}
</Link>
@@ -436,17 +466,22 @@ const Layout = ({ children }) => {
</div>
{/* Updated info */}
{stats && (
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
<Clock className="h-3 w-3" />
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0"
title="Refresh data"
>
<RefreshCw className="h-3 w-3" />
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
v{versionInfo.version}
</span>
)}
</div>
</div>
)}
@@ -473,7 +508,7 @@ const Layout = ({ children }) => {
</button>
{/* Updated info for collapsed sidebar */}
{stats && (
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex flex-col items-center py-1 border-t border-secondary-200 dark:border-secondary-700">
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
@@ -481,6 +516,11 @@ const Layout = ({ children }) => {
>
<RefreshCw className="h-3 w-3" />
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
v{versionInfo.version}
</span>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,15 @@
import React from 'react'
import { ArrowUpCircle } from 'lucide-react'
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
if (!show) return null
return (
<ArrowUpCircle
className={`${className} text-red-500 animate-pulse`}
title="Update available"
/>
)
}
export default UpgradeNotificationIcon

View File

@@ -0,0 +1,35 @@
import React, { createContext, useContext, useState } from 'react'
const UpdateNotificationContext = createContext()
export const useUpdateNotification = () => {
const context = useContext(UpdateNotificationContext)
if (!context) {
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
}
return context
}
export const UpdateNotificationProvider = ({ children }) => {
const [updateAvailable, setUpdateAvailable] = useState(false)
const [updateInfo, setUpdateInfo] = useState(null)
const dismissNotification = () => {
setUpdateAvailable(false)
setUpdateInfo(null)
}
const value = {
updateAvailable,
updateInfo,
dismissNotification,
isLoading: false,
error: null
}
return (
<UpdateNotificationContext.Provider value={value}>
{children}
</UpdateNotificationContext.Provider>
)
}

View File

@@ -8,7 +8,8 @@ import {
Shield,
TrendingUp,
RefreshCw,
Clock
Clock,
WifiOff
} from 'lucide-react'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
import { Pie, Bar } from 'react-chartjs-2'
@@ -46,6 +47,10 @@ const Dashboard = () => {
navigate('/hosts?filter=inactive')
}
const handleOfflineHostsClick = () => {
navigate('/hosts?filter=offline')
}
const handleOSDistributionClick = () => {
navigate('/hosts')
}
@@ -151,7 +156,7 @@ const Dashboard = () => {
const getCardType = (cardId) => {
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
return 'stats';
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
} else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority'].includes(cardId)) {
return 'charts';
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
return 'fullwidth';
@@ -295,6 +300,45 @@ const Dashboard = () => {
</div>
);
case 'offlineHosts':
return (
<div
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
stats.cards.offlineHosts > 0
? 'bg-warning-50 border-warning-200'
: 'bg-success-50 border-success-200'
}`}
onClick={handleOfflineHostsClick}
>
<div className="flex">
<WifiOff className={`h-5 w-5 ${
stats.cards.offlineHosts > 0 ? 'text-warning-400' : 'text-success-400'
}`} />
<div className="ml-3">
{stats.cards.offlineHosts > 0 ? (
<>
<h3 className="text-sm font-medium text-warning-800">
{stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale
</h3>
<p className="text-sm text-warning-700 mt-1">
These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes.
</p>
</>
) : (
<>
<h3 className="text-sm font-medium text-success-800">
All hosts are online
</h3>
<p className="text-sm text-success-700 mt-1">
No hosts are offline or stale.
</p>
</>
)}
</div>
</div>
</div>
);
case 'osDistribution':
return (
<div
@@ -308,6 +352,19 @@ const Dashboard = () => {
</div>
);
case 'osDistributionBar':
return (
<div
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleOSDistributionClick}
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
<div className="h-64">
<Bar data={osBarChartData} options={barChartOptions} />
</div>
</div>
);
case 'updateStatus':
return (
<div
@@ -414,6 +471,40 @@ const Dashboard = () => {
},
}
const barChartOptions = {
responsive: true,
indexAxis: 'y', // Make the chart horizontal
plugins: {
legend: {
display: false
},
},
scales: {
x: {
ticks: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
},
grid: {
color: isDark ? '#374151' : '#e5e7eb'
}
},
y: {
ticks: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
},
grid: {
color: isDark ? '#374151' : '#e5e7eb'
}
}
}
}
const osChartData = {
labels: stats.charts.osDistribution.map(item => item.name),
datasets: [
@@ -433,6 +524,28 @@ const Dashboard = () => {
],
}
const osBarChartData = {
labels: stats.charts.osDistribution.map(item => item.name),
datasets: [
{
label: 'Hosts',
data: stats.charts.osDistribution.map(item => item.count),
backgroundColor: [
'#3B82F6', // Blue
'#10B981', // Green
'#F59E0B', // Yellow
'#EF4444', // Red
'#8B5CF6', // Purple
'#06B6D4', // Cyan
],
borderWidth: 1,
borderColor: isDark ? '#374151' : '#ffffff',
borderRadius: 4,
borderSkipped: false,
},
],
}
const updateStatusChartData = {
labels: stats.charts.updateStatusDistribution.map(item => item.name),
datasets: [

View File

@@ -1,16 +1,22 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { authAPI } from '../utils/api'
const Login = () => {
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [tfaData, setTfaData] = useState({
token: ''
})
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [requiresTfa, setRequiresTfa] = useState(false)
const [tfaUsername, setTfaUsername] = useState('')
const navigate = useNavigate()
const { login } = useAuth()
@@ -21,16 +27,52 @@ const Login = () => {
setError('')
try {
const result = await login(formData.username, formData.password)
const response = await authAPI.login(formData.username, formData.password)
if (result.success) {
if (response.data.requiresTfa) {
setRequiresTfa(true)
setTfaUsername(formData.username)
setError('')
} else {
// Regular login successful
const result = await login(formData.username, formData.password)
if (result.success) {
navigate('/')
} else {
setError(result.error || 'Login failed')
}
}
} catch (err) {
setError(err.response?.data?.error || 'Login failed')
} finally {
setIsLoading(false)
}
}
const handleTfaSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
if (response.data && response.data.token) {
// Store token and user data
localStorage.setItem('token', response.data.token)
localStorage.setItem('user', JSON.stringify(response.data.user))
// Redirect to dashboard
navigate('/')
} else {
setError(result.error || 'Login failed')
setError('TFA verification failed - invalid response')
}
} catch (err) {
setError('Network error occurred')
console.error('TFA verification error:', err)
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
setError(errorMessage)
// Clear the token input for security
setTfaData({ token: '' })
} finally {
setIsLoading(false)
}
@@ -43,6 +85,23 @@ const Login = () => {
})
}
const handleTfaInputChange = (e) => {
setTfaData({
...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
})
// Clear error when user starts typing
if (error) {
setError('')
}
}
const handleBackToLogin = () => {
setRequiresTfa(false)
setTfaData({ token: '' })
setError('')
}
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
@@ -58,7 +117,8 @@ const Login = () => {
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{!requiresTfa ? (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
@@ -150,6 +210,83 @@ const Login = () => {
</p>
</div>
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<Smartphone className="h-6 w-6 text-blue-600" />
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900">
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600">
Enter the 6-digit code from your authenticator app
</p>
</div>
<div>
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
Verification Code
</label>
<div className="mt-1">
<input
id="token"
name="token"
type="text"
required
value={tfaData.token}
onChange={handleTfaInputChange}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
placeholder="000000"
maxLength="6"
/>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div className="space-y-3">
<button
type="submit"
disabled={isLoading || tfaData.token.length !== 6}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Verifying...
</div>
) : (
'Verify Code'
)}
</button>
<button
type="button"
onClick={handleBackToLogin}
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Login
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600">
Don't have access to your authenticator? Use a backup code.
</p>
</div>
</form>
)}
</div>
</div>
)

View File

@@ -0,0 +1,570 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
const Options = () => {
const [activeTab, setActiveTab] = useState('hostgroups')
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [selectedGroup, setSelectedGroup] = useState(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [groupToDelete, setGroupToDelete] = useState(null)
const queryClient = useQueryClient()
// Tab configuration
const tabs = [
{ id: 'hostgroups', name: 'Host Groups', icon: Users },
{ id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
]
// Fetch host groups
const { data: hostGroups, isLoading, error } = useQuery({
queryKey: ['hostGroups'],
queryFn: () => hostGroupsAPI.list().then(res => res.data),
})
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowCreateModal(false)
},
onError: (error) => {
console.error('Failed to create host group:', error)
}
})
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowEditModal(false)
setSelectedGroup(null)
},
onError: (error) => {
console.error('Failed to update host group:', error)
}
})
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowDeleteModal(false)
setGroupToDelete(null)
},
onError: (error) => {
console.error('Failed to delete host group:', error)
}
})
const handleCreate = (data) => {
createMutation.mutate(data)
}
const handleEdit = (group) => {
setSelectedGroup(group)
setShowEditModal(true)
}
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data })
}
const handleDeleteClick = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
}
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id)
}
const renderHostGroupsTab = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'}
</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Host Groups
</h2>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Grid */}
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
</div>
)
}
const renderComingSoonTab = (tabName) => (
<div className="text-center py-12">
<SettingsIcon className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
{tabName} Coming Soon
</h3>
<p className="text-secondary-600 dark:text-secondary-300">
This feature is currently under development and will be available in a future update.
</p>
</div>
)
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Options
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mt-1">
Configure PatchMon parameters and user preferences
</p>
</div>
{/* Tabs */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
{tab.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</button>
)
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">
{activeTab === 'hostgroups' && renderHostGroupsTab()}
{activeTab === 'notifications' && renderComingSoonTab('Notifications')}
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false)
setSelectedGroup(null)
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false)
setGroupToDelete(null)
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
)
}
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
color: '#3B82F6'
})
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>
</div>
</div>
)
}
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: group.name,
description: group.description || '',
color: group.color || '#3B82F6'
})
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
</button>
</div>
</form>
</div>
</div>
)
}
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{' '}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
You must move or remove these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? 'Deleting...' : 'Delete Group'}
</button>
</div>
</div>
</div>
)
}
export default Options

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
User,
Mail,
@@ -13,8 +14,15 @@ import {
AlertCircle,
Sun,
Moon,
Settings
Settings,
Smartphone,
QrCode,
Copy,
Download,
Trash2,
RefreshCw
} from 'lucide-react'
import { tfaAPI } from '../utils/api'
const Profile = () => {
const { user, updateProfile, changePassword } = useAuth()
@@ -111,6 +119,7 @@ const Profile = () => {
const tabs = [
{ id: 'profile', name: 'Profile Information', icon: User },
{ id: 'password', name: 'Change Password', icon: Key },
{ id: 'tfa', name: 'Multi-Factor Authentication', icon: Smartphone },
{ id: 'preferences', name: 'Preferences', icon: Settings }
]
@@ -357,6 +366,11 @@ const Profile = () => {
</form>
)}
{/* Multi-Factor Authentication Tab */}
{activeTab === 'tfa' && (
<TfaTab />
)}
{/* Preferences Tab */}
{activeTab === 'preferences' && (
<div className="space-y-6">
@@ -411,4 +425,399 @@ const Profile = () => {
)
}
// TFA Tab Component
const TfaTab = () => {
const [setupStep, setSetupStep] = useState('status') // 'status', 'setup', 'verify', 'backup-codes'
const [verificationToken, setVerificationToken] = useState('')
const [password, setPassword] = useState('')
const [backupCodes, setBackupCodes] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const queryClient = useQueryClient()
// Fetch TFA status
const { data: tfaStatus, isLoading: statusLoading } = useQuery({
queryKey: ['tfaStatus'],
queryFn: () => tfaAPI.status().then(res => res.data),
})
// Setup TFA mutation
const setupMutation = useMutation({
mutationFn: () => tfaAPI.setup().then(res => res.data),
onSuccess: (data) => {
setSetupStep('setup')
setMessage({ type: 'info', text: 'Scan the QR code with your authenticator app and enter the verification code below.' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to setup TFA' })
}
})
// Verify setup mutation
const verifyMutation = useMutation({
mutationFn: (data) => tfaAPI.verifySetup(data).then(res => res.data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes)
setSetupStep('backup-codes')
setMessage({ type: 'success', text: 'Two-factor authentication has been enabled successfully!' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to verify TFA setup' })
}
})
// Disable TFA mutation
const disableMutation = useMutation({
mutationFn: (data) => tfaAPI.disable(data).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['tfaStatus'])
setSetupStep('status')
setMessage({ type: 'success', text: 'Two-factor authentication has been disabled successfully!' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to disable TFA' })
}
})
// Regenerate backup codes mutation
const regenerateBackupCodesMutation = useMutation({
mutationFn: () => tfaAPI.regenerateBackupCodes().then(res => res.data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes)
setMessage({ type: 'success', text: 'Backup codes have been regenerated successfully!' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to regenerate backup codes' })
}
})
const handleSetup = () => {
setupMutation.mutate()
}
const handleVerify = (e) => {
e.preventDefault()
if (verificationToken.length !== 6) {
setMessage({ type: 'error', text: 'Please enter a 6-digit verification code' })
return
}
verifyMutation.mutate({ token: verificationToken })
}
const handleDisable = (e) => {
e.preventDefault()
if (!password) {
setMessage({ type: 'error', text: 'Please enter your password to disable TFA' })
return
}
disableMutation.mutate({ password })
}
const handleRegenerateBackupCodes = () => {
regenerateBackupCodesMutation.mutate()
}
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
setMessage({ type: 'success', text: 'Copied to clipboard!' })
}
const downloadBackupCodes = () => {
const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join('\n')}\n\nKeep these codes safe! Each code can only be used once.`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'patchmon-backup-codes.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
if (statusLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Multi-Factor Authentication</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Add an extra layer of security to your account by enabling two-factor authentication.
</p>
</div>
{/* Status Message */}
{message.text && (
<div className={`rounded-md p-4 ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
: message.type === 'error'
? 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
: 'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700'
}`}>
<div className="flex">
{message.type === 'success' ? (
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
) : message.type === 'error' ? (
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
) : (
<AlertCircle className="h-5 w-5 text-blue-400 dark:text-blue-300" />
)}
<div className="ml-3">
<p className={`text-sm font-medium ${
message.type === 'success' ? 'text-green-800 dark:text-green-200' :
message.type === 'error' ? 'text-red-800 dark:text-red-200' :
'text-blue-800 dark:text-blue-200'
}`}>
{message.text}
</p>
</div>
</div>
</div>
)}
{/* TFA Status */}
{setupStep === 'status' && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${tfaStatus?.enabled ? 'bg-green-100 dark:bg-green-900' : 'bg-secondary-100 dark:bg-secondary-700'}`}>
<Smartphone className={`h-6 w-6 ${tfaStatus?.enabled ? 'text-green-600 dark:text-green-400' : 'text-secondary-600 dark:text-secondary-400'}`} />
</div>
<div>
<h4 className="text-lg font-medium text-secondary-900 dark:text-white">
{tfaStatus?.enabled ? 'Two-Factor Authentication Enabled' : 'Two-Factor Authentication Disabled'}
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
{tfaStatus?.enabled
? 'Your account is protected with two-factor authentication.'
: 'Add an extra layer of security to your account.'
}
</p>
</div>
</div>
<div>
{tfaStatus?.enabled ? (
<button
onClick={() => setSetupStep('disable')}
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
>
<Trash2 className="h-4 w-4 mr-2" />
Disable TFA
</button>
) : (
<button
onClick={handleSetup}
disabled={setupMutation.isPending}
className="btn-primary"
>
<Smartphone className="h-4 w-4 mr-2" />
{setupMutation.isPending ? 'Setting up...' : 'Enable TFA'}
</button>
)}
</div>
</div>
</div>
{tfaStatus?.enabled && (
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Use these backup codes to access your account if you lose your authenticator device.
</p>
<button
onClick={handleRegenerateBackupCodes}
disabled={regenerateBackupCodesMutation.isPending}
className="btn-outline"
>
<RefreshCw className={`h-4 w-4 mr-2 ${regenerateBackupCodesMutation.isPending ? 'animate-spin' : ''}`} />
{regenerateBackupCodesMutation.isPending ? 'Regenerating...' : 'Regenerate Codes'}
</button>
</div>
)}
</div>
)}
{/* TFA Setup */}
{setupStep === 'setup' && setupMutation.data && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Setup Two-Factor Authentication</h4>
<div className="space-y-4">
<div className="text-center">
<img
src={setupMutation.data.qrCode}
alt="QR Code"
className="mx-auto h-48 w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
/>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
Scan this QR code with your authenticator app
</p>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<p className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Manual Entry Key:</p>
<div className="flex items-center space-x-2">
<code className="flex-1 bg-white dark:bg-secondary-800 px-3 py-2 rounded border text-sm font-mono">
{setupMutation.data.manualEntryKey}
</code>
<button
onClick={() => copyToClipboard(setupMutation.data.manualEntryKey)}
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
title="Copy to clipboard"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div className="text-center">
<button
onClick={() => setSetupStep('verify')}
className="btn-primary"
>
Continue to Verification
</button>
</div>
</div>
</div>
</div>
)}
{/* TFA Verification */}
{setupStep === 'verify' && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Verify Setup</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Enter the 6-digit code from your authenticator app to complete the setup.
</p>
<form onSubmit={handleVerify} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Verification Code
</label>
<input
type="text"
value={verificationToken}
onChange={(e) => setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
maxLength="6"
required
/>
</div>
<div className="flex space-x-3">
<button
type="submit"
disabled={verifyMutation.isPending || verificationToken.length !== 6}
className="btn-primary"
>
{verifyMutation.isPending ? 'Verifying...' : 'Verify & Enable'}
</button>
<button
type="button"
onClick={() => setSetupStep('status')}
className="btn-outline"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
{/* Backup Codes */}
{setupStep === 'backup-codes' && backupCodes.length > 0 && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Save these backup codes in a safe place. Each code can only be used once.
</p>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg mb-4">
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
{backupCodes.map((code, index) => (
<div key={index} className="flex items-center justify-between py-1">
<span className="text-secondary-600 dark:text-secondary-400">{index + 1}.</span>
<span className="text-secondary-900 dark:text-white">{code}</span>
</div>
))}
</div>
</div>
<div className="flex space-x-3">
<button
onClick={downloadBackupCodes}
className="btn-outline"
>
<Download className="h-4 w-4 mr-2" />
Download Codes
</button>
<button
onClick={() => {
setSetupStep('status')
queryClient.invalidateQueries(['tfaStatus'])
}}
className="btn-primary"
>
Done
</button>
</div>
</div>
</div>
)}
{/* Disable TFA */}
{setupStep === 'disable' && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Disable Two-Factor Authentication</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Enter your password to disable two-factor authentication.
</p>
<form onSubmit={handleDisable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
/>
</div>
<div className="flex space-x-3">
<button
type="submit"
disabled={disableMutation.isPending || !password}
className="btn-danger"
>
{disableMutation.isPending ? 'Disabling...' : 'Disable TFA'}
</button>
<button
type="button"
onClick={() => setSetupStep('status')}
className="btn-outline"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default Profile

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon } from 'lucide-react';
import { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api';
import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
const Settings = () => {
const [formData, setFormData] = useState({
@@ -12,6 +14,7 @@ const Settings = () => {
updateInterval: 60,
autoUpdate: false,
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: 'public',
sshKeyPath: '',
useCustomSshKey: false
});
@@ -21,12 +24,15 @@ const Settings = () => {
// Tab management
const [activeTab, setActiveTab] = useState('server');
// Get update notification state
const { updateAvailable } = useUpdateNotification();
// Tab configuration
const tabs = [
{ id: 'server', name: 'Server Configuration', icon: Server },
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
{ id: 'version', name: 'Server Version', icon: Code }
{ id: 'version', name: 'Server Version', icon: Code, showUpgradeIcon: updateAvailable }
];
// Agent version management state
@@ -76,6 +82,7 @@ const Settings = () => {
updateInterval: settings.updateInterval || 60,
autoUpdate: settings.autoUpdate || false,
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: settings.repositoryType || 'public',
sshKeyPath: settings.sshKeyPath || '',
useCustomSshKey: !!settings.sshKeyPath
};
@@ -107,6 +114,7 @@ const Settings = () => {
updateInterval: data.settings?.updateInterval || data.updateInterval || 60,
autoUpdate: data.settings?.autoUpdate || data.autoUpdate || false,
githubRepoUrl: data.settings?.githubRepoUrl || data.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: data.settings?.repositoryType || data.repositoryType || 'public',
sshKeyPath: data.settings?.sshKeyPath || data.sshKeyPath || '',
useCustomSshKey: !!(data.settings?.sshKeyPath || data.sshKeyPath)
});
@@ -301,6 +309,7 @@ const Settings = () => {
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
console.log('Submitting data with githubRepoUrl:', dataToSubmit.githubRepoUrl);
updateSettingsMutation.mutate(dataToSubmit);
} else {
console.log('Validation failed:', errors);
@@ -368,6 +377,9 @@ const Settings = () => {
>
<Icon className="h-4 w-4" />
{tab.name}
{tab.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</button>
);
})}
@@ -774,13 +786,52 @@ const Settings = () => {
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Repository Type
</label>
<div className="space-y-2">
<div className="flex items-center">
<input
type="radio"
id="repo-public"
name="repositoryType"
value="public"
checked={formData.repositoryType === 'public'}
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label htmlFor="repo-public" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
Public Repository (uses GitHub API - no authentication required)
</label>
</div>
<div className="flex items-center">
<input
type="radio"
id="repo-private"
name="repositoryType"
value="private"
checked={formData.repositoryType === 'private'}
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label htmlFor="repo-private" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
Private Repository (uses SSH with deploy key)
</label>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Choose whether your repository is public or private to determine the appropriate access method.
</p>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
GitHub Repository URL
</label>
<input
type="text"
value={formData.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'}
value={formData.githubRepoUrl || ''}
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="git@github.com:username/repository.git"
@@ -790,25 +841,26 @@ const Settings = () => {
</p>
</div>
<div>
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id="useCustomSshKey"
checked={formData.useCustomSshKey}
onChange={(e) => {
const checked = e.target.checked;
handleInputChange('useCustomSshKey', checked);
if (!checked) {
handleInputChange('sshKeyPath', '');
}
}}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
Set custom SSH key path
</label>
</div>
{formData.repositoryType === 'private' && (
<div>
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id="useCustomSshKey"
checked={formData.useCustomSshKey}
onChange={(e) => {
const checked = e.target.checked;
handleInputChange('useCustomSshKey', checked);
if (!checked) {
handleInputChange('sshKeyPath', '');
}
}}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
Set custom SSH key path
</label>
</div>
{formData.useCustomSshKey && (
<div>
@@ -866,7 +918,8 @@ const Settings = () => {
Using auto-detection for SSH key location
</p>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">

View File

@@ -31,9 +31,17 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
localStorage.removeItem('token')
window.location.href = '/login'
// Don't redirect if we're on the login page or if it's a TFA verification error
const currentPath = window.location.pathname
const isTfaError = error.config?.url?.includes('/verify-tfa')
if (currentPath !== '/login' && !isTfaError) {
// Handle unauthorized
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
@@ -185,6 +193,22 @@ export const versionAPI = {
testSshKey: (data) => api.post('/version/test-ssh-key', data),
}
// Auth API
export const authAPI = {
login: (username, password) => api.post('/auth/login', { username, password }),
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
}
// TFA API
export const tfaAPI = {
setup: () => api.get('/tfa/setup'),
verifySetup: (data) => api.post('/tfa/verify-setup', data),
disable: (data) => api.post('/tfa/disable', data),
status: () => api.get('/tfa/status'),
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
verify: (data) => api.post('/tfa/verify', data),
}
export const formatRelativeTime = (date) => {
const now = new Date()
const diff = now - new Date(date)

233
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.0.0",
"version": "1.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.0.0",
"version": "1.2.4",
"workspaces": [
"backend",
"frontend"
@@ -20,7 +20,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.0.0",
"version": "1.2.4",
"dependencies": {
"@prisma/client": "^5.7.0",
"bcryptjs": "^2.4.3",
@@ -32,6 +32,8 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},
@@ -45,7 +47,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.0.0",
"version": "1.2.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -1887,7 +1889,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1897,7 +1898,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2182,6 +2182,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/base32.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -2355,6 +2361,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2491,7 +2506,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -2768,6 +2782,15 @@
"ms": "2.0.0"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2846,6 +2869,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2925,7 +2954,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/enabled": {
@@ -3858,7 +3886,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4404,7 +4431,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5523,6 +5549,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5564,7 +5599,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5667,6 +5701,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5926,6 +5969,141 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -6155,12 +6333,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -6456,6 +6639,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6670,6 +6859,18 @@
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/speakeasy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
"license": "MIT",
"dependencies": {
"base32.js": "0.0.1"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -6715,7 +6916,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -6844,7 +7044,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -7627,6 +7826,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",