Ajout du système d'administration avec token unique et QR code

- Implémentation complète du système d'administration (/admin)
- Gestion des clients avec base de données JSON
- Génération de token unique et QR code pour chaque client
- Intégration des données client dans l'application (bungalow, WiFi, message)
- Amélioration du composant WifiCard avec fallback de copie
- Optimisation du hook useClientData pour chargement immédiat
- Ajout de la variable d'environnement ADMIN_PASSWORD
This commit is contained in:
2025-11-23 08:55:50 +01:00
parent 444a2729ee
commit f633dbb1c0
19 changed files with 1076 additions and 22 deletions

View File

@ -1,24 +1,75 @@
"use client";
import { useState } from "react";
import { Wifi, Copy, Check } from "lucide-react";
import { useState, useEffect } from "react";
import { Wifi, Copy, Check, AlertCircle } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { config } from "@/lib/config";
import { useClientData } from "@/lib/hooks/useClientData";
export default function WifiCard() {
const { wifiName, wifiPassword, loading } = useClientData();
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCopyPassword = async () => {
// Fonction de copie avec fallback pour les navigateurs qui ne supportent pas l'API Clipboard
const copyToClipboard = async (text: string): Promise<boolean> => {
// Méthode moderne (nécessite HTTPS ou localhost)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Erreur avec l'API Clipboard:", err);
}
}
// Fallback pour les navigateurs plus anciens ou contextes non sécurisés
try {
await navigator.clipboard.writeText(config.wifiPassword);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
if (successful) {
return true;
} else {
throw new Error("La commande copy a échoué");
}
} catch (err) {
console.error("Erreur lors de la copie:", err);
console.error("Erreur avec la méthode fallback:", err);
return false;
}
};
const handleCopyPassword = async () => {
if (!wifiPassword || wifiPassword.trim() === "") {
setError("Le mot de passe WiFi n'est pas disponible");
setTimeout(() => setError(null), 3000);
return;
}
setError(null);
const success = await copyToClipboard(wifiPassword);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
setError("Impossible de copier. Veuillez sélectionner manuellement le mot de passe ci-dessous.");
setTimeout(() => setError(null), 5000);
}
};
// Afficher le mot de passe en cas d'échec de la copie
const showPasswordFallback = error && error.includes("sélectionner manuellement");
return (
<Card className="bg-white">
<CardHeader>
@ -30,14 +81,36 @@ export default function WifiCard() {
<CardContent className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-1">Nom du réseau</p>
<p className="text-lg font-semibold text-primary">{config.wifiName}</p>
<p className="text-lg font-semibold text-primary">{wifiName || "Chargement..."}</p>
</div>
{showPasswordFallback && wifiPassword && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-3">
<p className="text-sm text-yellow-800 font-mono select-all">
{wifiPassword}
</p>
</div>
)}
{error && !showPasswordFallback && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<Button
onClick={handleCopyPassword}
disabled={loading || !wifiPassword}
className="w-full h-14 text-lg"
size="lg"
>
{copied ? (
{loading ? (
<>
<Copy className="mr-2 h-5 w-5" />
Chargement...
</>
) : copied ? (
<>
<Check className="mr-2 h-5 w-5" />
Mot de passe copié !

View File

@ -0,0 +1,30 @@
"use client";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const handleLogout = () => {
localStorage.removeItem("adminPassword");
router.push("/admin/login");
};
return (
<div className="min-h-screen bg-background">
<header className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-primary">Administration</h1>
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</Button>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-6">{children}</main>
</div>
);
}

View File

@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Client, ClientInput } from "@/lib/types/client";
import QRCodeDisplay from "./QRCodeDisplay";
interface ClientFormProps {
client?: Client;
onSuccess: () => void;
onCancel: () => void;
}
export default function ClientForm({ client, onSuccess, onCancel }: ClientFormProps) {
const [formData, setFormData] = useState<ClientInput>({
email: client?.email || "",
bungalowNumber: client?.bungalowNumber || "",
wifiName: client?.wifiName || "Lagon-WiFi",
wifiPassword: client?.wifiPassword || "",
gerantMessage: client?.gerantMessage || "Bienvenue dans notre pension de famille !",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdClient, setCreatedClient] = useState<Client | null>(client || null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const adminPassword = localStorage.getItem("adminPassword") || "";
const url = client
? `/api/admin/clients/${client.id}`
: "/api/admin/clients";
const method = client ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminPassword}`,
},
body: JSON.stringify(formData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Erreur lors de la sauvegarde");
}
const data = await response.json();
setCreatedClient(data);
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const getClientUrl = () => {
if (!createdClient) return "";
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
return `${baseUrl}/accueil?token=${createdClient.token}`;
};
return (
<Card>
<CardHeader>
<CardTitle>{client ? "Modifier le client" : "Nouveau client"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
disabled={!!client}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Numéro de bungalow *
</label>
<input
type="text"
required
value={formData.bungalowNumber}
onChange={(e) =>
setFormData({ ...formData, bungalowNumber: e.target.value })
}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom du WiFi
</label>
<input
type="text"
value={formData.wifiName}
onChange={(e) =>
setFormData({ ...formData, wifiName: e.target.value })
}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mot de passe WiFi
</label>
<input
type="text"
value={formData.wifiPassword}
onChange={(e) =>
setFormData({ ...formData, wifiPassword: e.target.value })
}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Message du gérant
</label>
<textarea
value={formData.gerantMessage}
onChange={(e) =>
setFormData({ ...formData, gerantMessage: e.target.value })
}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={onCancel} className="flex-1">
Annuler
</Button>
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Enregistrement..." : client ? "Modifier" : "Créer"}
</Button>
</div>
</form>
{createdClient && !client && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="font-semibold text-primary mb-3">Client créé avec succès !</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-2">Lien unique :</p>
<div className="bg-secondary rounded-xl p-3 flex items-center justify-between gap-2">
<code className="text-xs break-all flex-1">{getClientUrl()}</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(getClientUrl());
}}
>
Copier
</Button>
</div>
</div>
<div>
<p className="text-sm text-gray-600 mb-2">QR Code :</p>
<QRCodeDisplay url={getClientUrl()} />
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,165 @@
"use client";
import { useState, useEffect } from "react";
import { Trash2, Edit, Copy, QrCode } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Client } from "@/lib/types/client";
import QRCodeDisplay from "./QRCodeDisplay";
interface ClientListProps {
onEdit: (client: Client) => void;
onRefresh: () => void;
}
export default function ClientList({ onEdit, onRefresh }: ClientListProps) {
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [showQR, setShowQR] = useState<string | null>(null);
useEffect(() => {
fetchClients();
}, []);
const fetchClients = async () => {
try {
const adminPassword = localStorage.getItem("adminPassword") || "";
const response = await fetch("/api/admin/clients", {
headers: {
Authorization: `Bearer ${adminPassword}`,
},
});
if (response.ok) {
const data = await response.json();
setClients(data);
}
} catch (error) {
console.error("Erreur lors du chargement des clients:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce client ?")) {
return;
}
try {
const adminPassword = localStorage.getItem("adminPassword") || "";
const response = await fetch(`/api/admin/clients/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${adminPassword}`,
},
});
if (response.ok) {
fetchClients();
onRefresh();
}
} catch (error) {
console.error("Erreur lors de la suppression:", error);
}
};
const getClientUrl = (token: string) => {
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
return `${baseUrl}/accueil?token=${token}`;
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-gray-600">Chargement...</p>
</div>
);
}
if (clients.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center">
<p className="text-gray-600">Aucun client pour le moment.</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{clients.map((client) => (
<Card key={client.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{client.email}</CardTitle>
<p className="text-sm text-gray-600 mt-1">
Bungalow {client.bungalowNumber}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
const url = getClientUrl(client.token);
navigator.clipboard.writeText(url);
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setShowQR(showQR === client.id ? null : client.id)
}
>
<QrCode className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onEdit(client)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(client.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">WiFi:</span> {client.wifiName}
</div>
<div>
<span className="font-medium">Message:</span>{" "}
{client.gerantMessage.substring(0, 50)}
{client.gerantMessage.length > 50 ? "..." : ""}
</div>
<div className="text-xs text-gray-500">
Créé le {new Date(client.createdAt).toLocaleDateString("fr-FR")}
</div>
</div>
{showQR === client.id && (
<div className="mt-4 pt-4 border-t border-gray-200">
<QRCodeDisplay url={getClientUrl(client.token)} size={150} />
</div>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import { QRCodeSVG } from "qrcode.react";
interface QRCodeDisplayProps {
url: string;
size?: number;
}
export default function QRCodeDisplay({ url, size = 200 }: QRCodeDisplayProps) {
return (
<div className="flex flex-col items-center gap-4 p-4 bg-white rounded-2xl border border-gray-200">
<QRCodeSVG value={url} size={size} level="H" />
<p className="text-xs text-gray-600 text-center break-all max-w-xs">
{url}
</p>
</div>
);
}