style(frontend): fmt

This commit is contained in:
tigattack
2025-09-24 22:06:42 +01:00
parent 591389a91f
commit b43b20fbe9
34 changed files with 14405 additions and 12628 deletions

View File

@@ -1,336 +1,359 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
X,
GripVertical,
Eye,
EyeOff,
Save,
RotateCcw,
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Eye,
EyeOff,
GripVertical,
RotateCcw,
Save,
Settings as SettingsIcon,
X,
} from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTheme } from "../contexts/ThemeContext";
import { dashboardPreferencesAPI } from "../utils/api";
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const { isDark } = useTheme();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.cardId });
const { isDark } = useTheme();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.cardId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? 'shadow-lg' : 'shadow-sm'
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
{card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">({card.typeLabel})</span>
) : null}
</div>
</div>
</div>
<button
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? "shadow-lg" : "shadow-sm"
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
{card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
({card.typeLabel})
</span>
) : null}
</div>
</div>
</div>
<button
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
};
const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ['dashboardPreferences'],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
enabled: isOpen
});
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
enabled: isOpen,
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
enabled: isOpen
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ["dashboardDefaultCards"],
queryFn: () =>
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
enabled: isOpen,
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(['dashboardPreferences']);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error('Failed to update dashboard preferences:', error);
}
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(
["dashboardPreferences"],
response.data.preferences,
);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(["dashboardPreferences"]);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error("Failed to update dashboard preferences:", error);
},
});
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
const typeLabelFor = (cardId) => {
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card';
if (cardId === 'osDistribution') return 'Pie chart';
if (cardId === 'osDistributionBar') return 'Bar chart';
if (cardId === 'updateStatus') return 'Pie chart';
if (cardId === 'packagePriority') return 'Pie chart';
if (cardId === 'recentUsers') return 'Table';
if (cardId === 'recentCollection') return 'Table';
if (cardId === 'quickStats') return 'Wide card';
return undefined;
};
const typeLabelFor = (cardId) => {
if (
[
"totalHosts",
"hostsNeedingUpdates",
"totalOutdatedPackages",
"securityUpdates",
"upToDateHosts",
"totalHostGroups",
"totalUsers",
"totalRepos",
].includes(cardId)
)
return "Top card";
if (cardId === "osDistribution") return "Pie chart";
if (cardId === "osDistributionBar") return "Bar chart";
if (cardId === "updateStatus") return "Pie chart";
if (cardId === "packagePriority") return "Pie chart";
if (cardId === "recentUsers") return "Table";
if (cardId === "recentCollection") return "Table";
if (cardId === "quickStats") return "Wide card";
return undefined;
};
// Merge user preferences with default cards
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId
);
return {
...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
})
.sort((a, b) => a.order - b.order);
setCards(mergedCards);
}
}, [preferences, defaultCards]);
// Merge user preferences with default cards
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId,
);
return {
...defaultCard,
enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
})
.sort((a, b) => a.order - b.order);
const handleDragEnd = (event) => {
const { active, over } = event;
setCards(mergedCards);
}
}, [preferences, defaultCards]);
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex(item => item.cardId === active.id);
const newIndex = items.findIndex(item => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values
return newItems.map((item, index) => ({
...item,
order: index
}));
});
setHasChanges(true);
}
};
const handleDragEnd = (event) => {
const { active, over } = event;
const handleToggle = (cardId) => {
setCards(prevCards =>
prevCards.map(card =>
card.cardId === cardId
? { ...card, enabled: !card.enabled }
: card
)
);
setHasChanges(true);
};
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex((item) => item.cardId === active.id);
const newIndex = items.findIndex((item) => item.cardId === over.id);
const handleSave = () => {
const preferences = cards.map(card => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order
}));
updatePreferencesMutation.mutate(preferences);
};
const newItems = arrayMove(items, oldIndex, newIndex);
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map(card => ({
...card,
enabled: true,
order: card.order
}));
setCards(resetCards);
setHasChanges(true);
}
};
// Update order values
return newItems.map((item, index) => ({
...item,
order: index,
}));
});
setHasChanges(true);
}
};
if (!isOpen) return null;
const handleToggle = (cardId) => {
setCards((prevCards) =>
prevCards.map((card) =>
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
),
);
setHasChanges(true);
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their visibility.
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
</p>
const handleSave = () => {
const preferences = cards.map((card) => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order,
}));
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
updatePreferencesMutation.mutate(preferences);
};
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map((card) => ({
...card,
enabled: true,
order: card.order,
}));
setCards(resetCards);
setHasChanges(true);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity"
onClick={onClose}
/>
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their
visibility. Drag cards to reorder them, and click the visibility
toggle to show/hide cards.
</p>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cards.map((card) => card.cardId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
};
export default DashboardSettingsModal;

View File

@@ -1,297 +1,321 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react'
import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
import React, { useState } from "react";
import { useAuth } from "../contexts/AuthContext";
const FirstTimeAdminSetup = () => {
const { login } = useAuth()
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: ''
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const { login } = useAuth();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
// Clear error when user starts typing
if (error) setError('')
}
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user starts typing
if (error) setError("");
};
const validateForm = () => {
if (!formData.firstName.trim()) {
setError('First name is required')
return false
}
if (!formData.lastName.trim()) {
setError('Last name is required')
return false
}
if (!formData.username.trim()) {
setError('Username is required')
return false
}
if (!formData.email.trim()) {
setError('Email address is required')
return false
}
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.email.trim())) {
setError('Please enter a valid email address (e.g., user@example.com)')
return false
}
if (formData.password.length < 8) {
setError('Password must be at least 8 characters for security')
return false
}
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match')
return false
}
return true
}
const validateForm = () => {
if (!formData.firstName.trim()) {
setError("First name is required");
return false;
}
if (!formData.lastName.trim()) {
setError("Last name is required");
return false;
}
if (!formData.username.trim()) {
setError("Username is required");
return false;
}
if (!formData.email.trim()) {
setError("Email address is required");
return false;
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!validateForm()) return
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email.trim())) {
setError("Please enter a valid email address (e.g., user@example.com)");
return false;
}
setIsLoading(true)
setError('')
if (formData.password.length < 8) {
setError("Password must be at least 8 characters for security");
return false;
}
if (formData.password !== formData.confirmPassword) {
setError("Passwords do not match");
return false;
}
return true;
};
try {
const response = await fetch('/api/v1/auth/setup-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: formData.username.trim(),
email: formData.email.trim(),
password: formData.password,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim()
})
})
const handleSubmit = async (e) => {
e.preventDefault();
const data = await response.json()
if (!validateForm()) return;
if (response.ok) {
setSuccess(true)
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password)
}, 2000)
} else {
setError(data.error || 'Failed to create admin user')
}
} catch (error) {
console.error('Setup error:', error)
setError('Network error. Please try again.')
} finally {
setIsLoading(false)
}
}
setIsLoading(true);
setError("");
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8 text-center">
<div className="flex justify-center mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
Admin Account Created!
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Your admin account has been successfully created. You will be automatically logged in shortly.
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
</div>
</div>
)
}
try {
const response = await fetch("/api/v1/auth/setup-admin", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: formData.username.trim(),
email: formData.email.trim(),
password: formData.password,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
}),
});
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 dark:bg-primary-900 p-4 rounded-full">
<Shield className="h-12 w-12 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Welcome to PatchMon
</h1>
<p className="text-secondary-600 dark:text-secondary-300">
Let's set up your admin account to get started
</p>
</div>
const data = await response.json();
{error && (
<div className="mb-6 p-4 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-lg">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" />
<span className="text-danger-700 dark:text-danger-300 text-sm">{error}</span>
</div>
</div>
)}
if (response.ok) {
setSuccess(true);
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password);
}, 2000);
} else {
setError(data.error || "Failed to create admin user");
}
} catch (error) {
console.error("Setup error:", error);
setError("Network error. Please try again.");
} finally {
setIsLoading(false);
}
};
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your first name"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your last name"
required
disabled={isLoading}
/>
</div>
</div>
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8 text-center">
<div className="flex justify-center mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
Admin Account Created!
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Your admin account has been successfully created. You will be
automatically logged in shortly.
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
</div>
</div>
);
}
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 dark:bg-primary-900 p-4 rounded-full">
<Shield className="h-12 w-12 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Welcome to PatchMon
</h1>
<p className="text-secondary-600 dark:text-secondary-300">
Let's set up your admin account to get started
</p>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="mb-6 p-4 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-lg">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" />
<span className="text-danger-700 dark:text-danger-300 text-sm">
{error}
</span>
</div>
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Password
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your password (min 8 characters)"
required
disabled={isLoading}
/>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your first name"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your last name"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className="input w-full"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Creating Admin Account...
</>
) : (
<>
<UserPlus className="h-4 w-4" />
Create Admin Account
</>
)}
</button>
</form>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div className="mt-8 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div className="flex items-start">
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Admin Privileges</p>
<p>This account will have full administrative access to manage users, hosts, packages, and system settings.</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Password
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your password (min 8 characters)"
required
disabled={isLoading}
/>
</div>
export default FirstTimeAdminSetup
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className="input w-full"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Creating Admin Account...
</>
) : (
<>
<UserPlus className="h-4 w-4" />
Create Admin Account
</>
)}
</button>
</form>
<div className="mt-8 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div className="flex items-start">
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Admin Privileges</p>
<p>
This account will have full administrative access to manage
users, hosts, packages, and system settings.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FirstTimeAdminSetup;

View File

@@ -1,157 +1,159 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Check, Edit2, X } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
const InlineEdit = ({
value,
onSave,
onCancel,
placeholder = "Enter value...",
maxLength = 100,
className = "",
disabled = false,
validate = null,
linkTo = null
const InlineEdit = ({
value,
onSave,
onCancel,
placeholder = "Enter value...",
maxLength = 100,
className = "",
disabled = false,
validate = null,
linkTo = null,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const inputRef = useRef(null);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const inputRef = useRef(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
setEditValue(value);
}, [value]);
useEffect(() => {
setEditValue(value);
}, [value]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError('');
};
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError("");
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError('');
if (onCancel) onCancel();
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError("");
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
const handleSave = async () => {
if (disabled || isLoading) return;
// Validate if validator function provided
if (validate) {
const validationError = validate(editValue);
if (validationError) {
setError(validationError);
return;
}
}
// Validate if validator function provided
if (validate) {
const validationError = validate(editValue);
if (validationError) {
setError(validationError);
return;
}
}
// Check if value actually changed
if (editValue.trim() === value.trim()) {
setIsEditing(false);
return;
}
// Check if value actually changed
if (editValue.trim() === value.trim()) {
setIsEditing(false);
return;
}
setIsLoading(true);
setError('');
setIsLoading(true);
setError("");
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
maxLength={maxLength}
disabled={isLoading}
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
/>
<button
onClick={handleSave}
disabled={isLoading || editValue.trim() === ''}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
)}
</div>
);
}
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
maxLength={maxLength}
disabled={isLoading}
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
error ? "border-red-500" : ""
} ${isLoading ? "opacity-50" : ""}`}
/>
<button
onClick={handleSave}
disabled={isLoading || editValue.trim() === ""}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">
{error}
</span>
)}
</div>
);
}
const displayValue = linkTo ? (
<Link
to={linkTo}
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
title="View details"
>
{value}
</Link>
) : (
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{value}
</span>
);
const displayValue = linkTo ? (
<Link
to={linkTo}
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
title="View details"
>
{value}
</Link>
) : (
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{value}
</span>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineEdit;

View File

@@ -1,257 +1,270 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
import { Check, ChevronDown, Edit2, X } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group..."
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group...",
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width
});
}
};
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
calculateDropdownPosition();
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', calculateDropdownPosition);
window.addEventListener('scroll', calculateDropdownPosition);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', calculateDropdownPosition);
window.removeEventListener('scroll', calculateDropdownPosition);
};
}
}, [isOpen]);
if (isOpen) {
calculateDropdownPosition();
document.addEventListener("mousedown", handleClickOutside);
window.addEventListener("resize", calculateDropdownPosition);
window.addEventListener("scroll", calculateDropdownPosition);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("resize", calculateDropdownPosition);
window.removeEventListener("scroll", calculateDropdownPosition);
};
}
}, [isOpen]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError('');
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError("");
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError('');
setIsOpen(false);
if (onCancel) onCancel();
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError("");
setIsOpen(false);
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
const handleSave = async () => {
if (disabled || isLoading) return;
// Check if value actually changed
if (selectedValue === value) {
setIsEditing(false);
setIsOpen(false);
return;
}
// Check if value actually changed
if (selectedValue === value) {
setIsEditing(false);
setIsOpen(false);
return;
}
setIsLoading(true);
setError('');
setIsLoading(true);
setError("");
try {
await onSave(selectedValue);
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
try {
await onSave(selectedValue);
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
const displayValue = useMemo(() => {
if (!value) {
return 'Ungrouped';
}
const option = options.find(opt => opt.id === value);
return option ? option.name : 'Unknown Group';
}, [value, options]);
const displayValue = useMemo(() => {
if (!value) {
return "Ungrouped";
}
const option = options.find((opt) => opt.id === value);
return option ? option.name : "Unknown Group";
}, [value, options]);
const displayColor = useMemo(() => {
if (!value) return 'bg-secondary-100 text-secondary-800';
const option = options.find(opt => opt.id === value);
return option ? `text-white` : 'bg-secondary-100 text-secondary-800';
}, [value, options]);
const displayColor = useMemo(() => {
if (!value) return "bg-secondary-100 text-secondary-800";
const option = options.find((opt) => opt.id === value);
return option ? `text-white` : "bg-secondary-100 text-secondary-800";
}, [value, options]);
const selectedOption = useMemo(() => {
return options.find(opt => opt.id === value);
}, [value, options]);
const selectedOption = useMemo(() => {
return options.find((opt) => opt.id === value);
}, [value, options]);
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
disabled={isLoading}
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
>
<span className="truncate">
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: '200px'
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
onClick={handleSave}
disabled={isLoading}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
)}
</div>
);
}
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
disabled={isLoading}
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
error ? "border-red-500" : ""
} ${isLoading ? "opacity-50" : ""}`}
>
<span className="truncate">
{selectedValue
? options.find((opt) => opt.id === selectedValue)?.name ||
"Unknown Group"
: "Ungrouped"}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
return (
<div className={`flex items-center gap-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{displayValue}
</span>
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: "200px",
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === null
? "bg-primary-50 dark:bg-primary-900/20"
: ""
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === option.id
? "bg-primary-50 dark:bg-primary-900/20"
: ""
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
onClick={handleSave}
disabled={isLoading}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
{error}
</span>
)}
</div>
);
}
return (
<div className={`flex items-center gap-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{displayValue}
</span>
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineGroupEdit;

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,59 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
const ProtectedRoute = ({
children,
requireAdmin = false,
requirePermission = null,
}) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
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 (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 (!isAuthenticated()) {
return <Navigate to="/login" replace />
}
if (!isAuthenticated()) {
return <Navigate to="/login" replace />;
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
</div>
</div>
)
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div>
</div>
);
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
</div>
</div>
)
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div>
</div>
);
}
return children
}
return children;
};
export default ProtectedRoute
export default ProtectedRoute;

View File

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