mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-10 08:55:44 +00:00
style(frontend): fmt
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user