Merge pull request #27 from BreizhHardware/dev

Dev
This commit is contained in:
Félix MARQUET
2025-11-20 18:35:36 +01:00
committed by GitHub
22 changed files with 2050 additions and 2065 deletions

View File

@@ -41,4 +41,4 @@ prisma/dev.db
INITIAL_SETUP.md
API_DOCS.md
INDICATIONS_POUR_LLMS.md
scripts/seed-test.js

View File

@@ -49,10 +49,10 @@ Une application web moderne pour la gestion des heures travaillées dans un club
```bash
# Appliquer le schéma Prisma
npx prisma db push
pnpx prisma db push
# (Optionnel) Générer le client Prisma
npx prisma generate
pnpx prisma generate
```
4. **Variables d'environnement**

View File

@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { toast } from 'sonner';
interface ParsedUser {
id: string;
email: string;
firstName: string;
lastName: string;
password: string;
role: string;
passwordResetRequired: boolean;
}
interface SheetResult {
sheetName: string;
users: ParsedUser[];
errors: string[];
preview: string[][];
}
export default function ImportUsersPage() {
const [file, setFile] = useState<File | null>(null);
const [results, setResults] = useState<SheetResult[]>([]);
const [loading, setLoading] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{ sheet: SheetResult | null; open: boolean }>({ sheet: null, open: false });
const handleFileUpload = async () => {
if (!file) return;
setLoading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/import-users', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to parse file');
}
const data = await response.json();
setResults(data.results);
toast.success('File parsed successfully');
} catch (error) {
toast.error('Error parsing file');
console.error(error);
} finally {
setLoading(false);
}
};
const handleConfirmImport = async (sheet: SheetResult) => {
try {
const response = await fetch('/api/import-users', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sheetName: sheet.sheetName,
users: sheet.users,
}),
});
if (!response.ok) {
throw new Error('Failed to import users');
}
const data = await response.json();
toast.success(`Imported ${data.createdUsers.length} users from ${sheet.sheetName}`);
toast.info('Les utilisateurs importés ont le mot de passe temporaire : "123456"');
if (data.errors.length > 0) {
toast.warning(`Some errors occurred: ${data.errors.join(', ')}`);
}
setConfirmDialog({ sheet: null, open: false });
// Refresh results or remove the sheet from results
setResults(results.filter(r => r.sheetName !== sheet.sheetName));
} catch (error) {
toast.error('Error importing users');
console.error(error);
}
};
return (
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Import Users from Excel</h1>
<Card>
<CardHeader>
<CardTitle>Upload Excel File</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<Label htmlFor="file">Select Excel File</Label>
<Input
id="file"
type="file"
accept=".xlsx,.xls"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
</div>
<Button onClick={handleFileUpload} disabled={!file || loading}>
{loading ? 'Parsing...' : 'Parse File'}
</Button>
</div>
</CardContent>
</Card>
{results.length > 0 && (
<div className="mt-6">
<h2 className="text-xl font-semibold mb-4">Parsed Sheets</h2>
<div className="space-y-4">
{results.map((sheet) => (
<Card key={sheet.sheetName}>
<CardHeader>
<CardTitle className="flex justify-between items-center">
{sheet.sheetName}
<Button
onClick={() => setConfirmDialog({ sheet, open: true })}
disabled={sheet.users.length === 0}
>
Import {sheet.users.length} Users
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{sheet.preview && sheet.preview.length > 0 && (
<div className="mb-4">
<h3 className="font-semibold mb-2">Aperçu des données (5 premières lignes) :</h3>
<div className="max-h-40 overflow-y-auto border rounded p-2 bg-gray-50">
{sheet.preview.map((row, index) => (
<div key={index} className="text-sm mb-1">
Ligne {index + 2}: {row.join(' | ')}
</div>
))}
</div>
<p className="text-sm text-gray-600 mt-2">
Colonnes attendues : NOM Prénom | Classe | Fonction dans le club | Mail
</p>
</div>
)}
{sheet.errors.length > 0 && (
<div className="mb-4">
<h3 className="font-semibold text-red-600">Erreurs :</h3>
<div className="max-h-40 overflow-y-auto">
{sheet.errors.map((error, index) => (
<li key={index} className="text-red-600 text-sm">{error}</li>
))}
</div>
</div>
)}
{sheet.users.length > 0 && (
<div>
<h3 className="font-semibold mb-2">Users to Import:</h3>
<div className="max-h-40 overflow-y-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left">Email</th>
<th className="text-left">First Name</th>
<th className="text-left">Last Name</th>
</tr>
</thead>
<tbody>
{sheet.users.map((user, index) => (
<tr key={index}>
<td>{user.email}</td>
<td>{user.firstName}</td>
<td>{user.lastName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
)}
<Dialog open={confirmDialog.open} onOpenChange={(open) => setConfirmDialog({ ...confirmDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Import</DialogTitle>
</DialogHeader>
<p>Are you sure you want to import {confirmDialog.sheet?.users.length} users from sheet "{confirmDialog.sheet?.sheetName}"?</p>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => setConfirmDialog({ sheet: null, open: false })}>
Cancel
</Button>
<Button onClick={() => confirmDialog.sheet && handleConfirmImport(confirmDialog.sheet)}>
Confirm
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -28,6 +28,14 @@ import {
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import { Checkbox } from '../../components/ui/checkbox';
import { toast } from 'sonner';
import { DatePicker } from '../../components/ui/date-picker';
import { format } from 'date-fns';
@@ -40,6 +48,7 @@ interface Hour {
reason: string;
status: string;
userId: string;
createdAt?: string;
user: { email: string; firstName?: string; lastName?: string; role: string };
validatedBy?: { firstName?: string; lastName?: string; email: string };
}
@@ -94,6 +103,10 @@ export default function AdminPage() {
const [confirmResetPassword, setConfirmResetPassword] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [confirmPasswordChange, setConfirmPasswordChange] = useState(false);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'date' | 'createdAt'>('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showPendingFirst, setShowPendingFirst] = useState(false);
const { refetchSettings } = useSettings();
useEffect(() => {
@@ -223,6 +236,7 @@ export default function AdminPage() {
date: dateString,
duration: totalMinutes,
reason,
userIds: selectedUserIds.length > 0 ? selectedUserIds : undefined,
}),
});
if (res.ok) {
@@ -230,6 +244,7 @@ export default function AdminPage() {
setHoursInput('');
setMinutesInput('');
setReason('');
setSelectedUserIds([]);
fetchHours();
toast.success('Heure ajoutée avec succès');
} else {
@@ -360,9 +375,27 @@ export default function AdminPage() {
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN';
const sortedHours = hours.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
const sortedHours = [...hours].sort((a, b) => {
if (showPendingFirst) {
if (a.status === 'PENDING' && b.status !== 'PENDING') return -1;
if (a.status !== 'PENDING' && b.status === 'PENDING') return 1;
}
let dateA, dateB;
if (sortBy === 'createdAt') {
dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
} else {
dateA = new Date(a.date).getTime();
dateB = new Date(b.date).getTime();
}
if (sortOrder === 'asc') {
return dateA - dateB;
} else {
return dateB - dateA;
}
});
const displayedHours = showAll ? sortedHours : sortedHours.slice(0, 10);
const formatHours = (minutes: number) => {
@@ -373,13 +406,48 @@ export default function AdminPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Administration</h1>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Administration</h1>
<Button onClick={() => router.push('/admin/import-users')}>
Importer des utilisateurs
</Button>
</div>
<Card className="mb-4">
<CardHeader>
<CardTitle>Ajouter des heures</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddHour} className="space-y-4">
<div>
<Label className="mb-2 block">
Utilisateurs (laisser vide pour vous-même)
</Label>
<div className="h-40 overflow-y-auto border rounded p-2 space-y-2 bg-white dark:bg-stone-900">
{users.map((user) => (
<div key={user.id} className="flex items-center space-x-2">
<Checkbox
id={`user-${user.id}`}
checked={selectedUserIds.includes(user.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedUserIds([...selectedUserIds, user.id]);
} else {
setSelectedUserIds(
selectedUserIds.filter((id) => id !== user.id),
);
}
}}
/>
<Label
htmlFor={`user-${user.id}`}
className="cursor-pointer font-normal"
>
{user.firstName} {user.lastName} ({user.email})
</Label>
</div>
))}
</div>
</div>
<div>
<Label htmlFor="date">Date</Label>
<DatePicker date={date} setDate={setDate} />
@@ -427,6 +495,59 @@ export default function AdminPage() {
<CardTitle>Gestion des heures</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4 mb-4 items-end">
<div className="w-[200px]">
<Label htmlFor="sortBy" className="mb-2 block">
Trier par
</Label>
<Select
value={sortBy}
onValueChange={(value) =>
setSortBy(value as 'date' | 'createdAt')
}
>
<SelectTrigger id="sortBy">
<SelectValue placeholder="Trier par" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date effective</SelectItem>
<SelectItem value="createdAt">Date d'ajout</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-[200px]">
<Label htmlFor="sortOrder" className="mb-2 block">
Ordre
</Label>
<Select
value={sortOrder}
onValueChange={(value) => setSortOrder(value as 'asc' | 'desc')}
>
<SelectTrigger id="sortOrder">
<SelectValue placeholder="Ordre" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Récent d'abord</SelectItem>
<SelectItem value="asc">Ancien d'abord</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pb-2">
<Checkbox
id="showPendingFirst"
checked={showPendingFirst}
onCheckedChange={(checked) =>
setShowPendingFirst(checked as boolean)
}
/>
<Label
htmlFor="showPendingFirst"
className="cursor-pointer font-normal"
>
Afficher les "En attente" en premier
</Label>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
@@ -647,16 +768,18 @@ export default function AdminPage() {
/>
</div>
<div>
<Label htmlFor="newRole">Rôle</Label>
<select
id="newRole"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
className="w-full p-2 border rounded bg-white text-black dark:bg-stone-800 dark:text-white dark:border-stone-600"
>
<option value="MEMBER">Membre</option>
<option value="ADMIN">Bureau</option>
</select>
<Label htmlFor="newRole" className="mb-2 block">
Rôle
</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger id="newRole">
<SelectValue placeholder="Rôle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Membre</SelectItem>
<SelectItem value="ADMIN">Bureau</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit">Créer</Button>
</form>
@@ -702,11 +825,10 @@ export default function AdminPage() {
<DialogDescription>
Êtes-vous sûr de vouloir supprimer cet utilisateur ?
<div className="flex items-center space-x-2 mt-2">
<input
type="checkbox"
<Checkbox
id="force"
checked={forceDelete}
onChange={(e) => setForceDelete(e.target.checked)}
onCheckedChange={(checked) => setForceDelete(checked as boolean)}
/>
<Label htmlFor="force">
Forcer la suppression même si l'utilisateur a des heures

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { newPassword } = await request.json();
if (!newPassword || newPassword.length < 6) {
return NextResponse.json({ error: 'Password must be at least 6 characters long' }, { status: 400 });
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: session.user.id },
data: {
password: hashedPassword,
passwordResetRequired: false,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Change password error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -24,6 +24,7 @@ export async function GET() {
reason: true,
status: true,
userId: true,
createdAt: true,
user: { select: { email: true, firstName: true, lastName: true } },
validatedBy: {
select: { firstName: true, lastName: true, email: true },
@@ -39,6 +40,7 @@ export async function GET() {
reason: true,
status: true,
userId: true,
createdAt: true,
user: {
select: { email: true, firstName: true, lastName: true, role: true },
},
@@ -65,7 +67,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
const { date, duration, reason } = await request.json();
const { date, duration, reason, userIds } = await request.json();
if (!date || !duration || !reason) {
return NextResponse.json(
@@ -74,17 +76,39 @@ export async function POST(request: NextRequest) {
);
}
const userId = session.user.id;
let targetUserIds = [session.user.id];
let status = 'PENDING';
let validatedById = undefined;
const hour = await prisma.hour.create({
data: {
id: uuidv4(),
date: new Date(date),
duration,
reason,
userId,
},
});
if (userIds && Array.isArray(userIds) && userIds.length > 0) {
if (session.user.role === 'ADMIN' || session.user.role === 'SUPER_ADMIN') {
targetUserIds = userIds;
status = 'VALIDATED';
validatedById = session.user.id;
} else {
return NextResponse.json(
{ error: "Non autorisé à ajouter des heures pour d'autres utilisateurs" },
{ status: 403 },
);
}
}
return NextResponse.json(hour);
const createdHours = [];
for (const uid of targetUserIds) {
const hour = await prisma.hour.create({
data: {
id: uuidv4(),
date: new Date(date),
duration,
reason,
userId: uid,
status: status as any,
validatedById,
},
});
createdHours.push(hour);
}
return NextResponse.json(createdHours);
}

View File

@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import bcrypt from 'bcryptjs';
import ExcelJS from 'exceljs';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const buffer = await file.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
const results: { sheetName: string; users: any[]; errors: string[]; preview: string[][] }[] = [];
const getCellValue = (cellValue: any): string => {
if (cellValue === null || cellValue === undefined) return '';
if (typeof cellValue === 'object') {
if ('text' in cellValue) return cellValue.text.toString();
if ('result' in cellValue) return cellValue.result?.toString() || '';
if ('hyperlink' in cellValue && 'text' in cellValue) return cellValue.text.toString();
}
return cellValue.toString();
};
for (const worksheet of workbook.worksheets) {
const sheetName = worksheet.name;
const users: any[] = [];
const errors: string[] = [];
const preview: string[][] = [];
// Collect preview of first 5 rows
let rowCount = 0;
worksheet.eachRow((row, rowNumber) => {
if (rowCount < 5) {
const cells = row.values as any[];
// Handle 1-based index of row.values where index 0 is usually undefined
// We want columns 1 to end.
const rowValues = Array.isArray(cells) ? cells : [];
// Slice from 1 to get actual columns if using row.values
const displayValues = rowValues.slice(1).map(cell => getCellValue(cell));
preview.push(displayValues);
rowCount++;
}
});
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // Skip header
const cells = row.values as any[];
// Columns: NOM Prénom, Classe, Fonction dans le club, Mail
// ExcelJS row.values is 1-based, so index 1 is column 1.
const fullName = getCellValue(cells[1]).trim();
const email = getCellValue(cells[4]).trim();
if (!fullName || !email) {
errors.push(`Row ${rowNumber}: Missing required fields (NOM Prénom: "${fullName}", Mail: "${email}")`);
return;
}
// Skip lines that don't look like user entries
// Must have name with at least one space (first last), and valid email
if (!fullName.includes(' ') || fullName.split(' ').length < 2) {
errors.push(`Row ${rowNumber}: Skipped - name doesn't look like "LastName FirstName" (NOM Prénom: "${fullName}")`);
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
errors.push(`Row ${rowNumber}: Invalid email format: "${email}"`);
return;
}
// Split fullName into firstName and lastName
// Assume format: "LastName FirstName"
const nameParts = fullName.split(' ');
const lastName = nameParts[0];
const firstName = nameParts.slice(1).join(' ');
users.push({
id: uuidv4(),
email,
firstName,
lastName,
password: '', // Will be set to require reset
role: 'MEMBER',
passwordResetRequired: true,
});
});
results.push({ sheetName, users, errors, preview });
}
// For now, return the parsed data for confirmation
return NextResponse.json({ results });
} catch (error) {
console.error('Import error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { sheetName, users } = await request.json();
if (!sheetName || !users) {
return NextResponse.json({ error: 'Missing sheetName or users' }, { status: 400 });
}
const createdUsers = [];
const errors = [];
for (const user of users) {
try {
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: user.email },
});
if (existingUser) {
errors.push(`User with email ${user.email} already exists`);
continue;
}
// Hash a temporary password
const hashedPassword = await bcrypt.hash('123456', 10);
const newUser = await prisma.user.create({
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
password: hashedPassword,
role: 'MEMBER',
passwordResetRequired: true,
},
});
createdUsers.push(newUser);
} catch (error) {
errors.push(`Error creating user ${user.email}: ${(error as Error).message}`);
}
}
return NextResponse.json({ createdUsers, errors });
} catch (error) {
console.error('Confirm import error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useEffect, useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
export default function ChangePasswordPage() {
const { data: session, update, status } = useSession();
const router = useRouter();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (status === 'authenticated' && !session?.user.passwordResetRequired) {
router.push('/dashboard');
}
}, [session, status, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (newPassword.length < 6) {
toast.error('Password must be at least 6 characters long');
return;
}
setLoading(true);
try {
const response = await fetch('/api/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ newPassword }),
});
if (!response.ok) {
throw new Error('Failed to change password');
}
toast.success('Password changed successfully');
await update({ passwordResetRequired: false }); // Update the session
router.push('/dashboard');
} catch (error) {
toast.error('Error changing password');
console.error(error);
} finally {
setLoading(false);
}
};
if (status === 'loading') {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
}
if (!session?.user.passwordResetRequired) {
return null; // Will redirect via useEffect
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Change Your Password</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
For security reasons, you must change your password before continuing.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Changing...' : 'Change Password'}
</Button>
</form>
<Button
variant="outline"
className="w-full mt-4"
onClick={() => signOut({ callbackUrl: '/login' })}
>
Logout
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useSession, signOut } from 'next-auth/react';
import { Button } from './ui/button';
import { useSettings } from '../context/SettingsContext';
import { ModeToggle } from './mode-toggle';
export default function Header() {
const { data: session } = useSession();
@@ -19,24 +20,27 @@ export default function Header() {
{settings.name || 'Club Scolaire'}
</h1>
</div>
{session && (
<div className="flex items-center space-x-4">
<span className="text-gray-900 dark:text-white">
{session.user.email} (
{session.user.role === 'MEMBER'
? 'Membre'
: session.user.role === 'ADMIN'
? 'Bureau'
: session.user.role === 'SUPER_ADMIN'
? 'Gestionnaire'
: session.user.role}
)
</span>
<Button onClick={() => signOut()} variant="destructive">
Déconnexion
</Button>
</div>
)}
<div className="flex items-center space-x-4">
<ModeToggle />
{session && (
<>
<span className="text-gray-900 dark:text-white">
{session.user.email} (
{session.user.role === 'MEMBER'
? 'Membre'
: session.user.role === 'ADMIN'
? 'Bureau'
: session.user.role === 'SUPER_ADMIN'
? 'Gestionnaire'
: session.user.role}
)
</span>
<Button onClick={() => signOut()} variant="destructive">
Déconnexion
</Button>
</>
)}
</div>
</div>
</header>
);

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { Moon, Sun, Laptop } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export function ModeToggle() {
const { setTheme, theme } = useTheme()
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Changer le thème</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-2">
<div className="grid gap-1">
<Button
variant={theme === "light" ? "secondary" : "ghost"}
className="w-full justify-start gap-2"
onClick={() => setTheme("light")}
>
<Sun className="h-4 w-4" />
<span>Clair</span>
</Button>
<Button
variant={theme === "dark" ? "secondary" : "ghost"}
className="w-full justify-start gap-2"
onClick={() => setTheme("dark")}
>
<Moon className="h-4 w-4" />
<span>Sombre</span>
</Button>
<Button
variant={theme === "system" ? "secondary" : "ghost"}
className="w-full justify-start gap-2"
onClick={() => setTheme("system")}
>
<Laptop className="h-4 w-4" />
<span>Système</span>
</Button>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,14 +1,38 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { SessionProvider, useSession } from 'next-auth/react';
import { ThemeProvider } from 'next-themes';
import { SettingsProvider } from '../context/SettingsContext';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';
function PasswordResetGuard({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (status === 'loading') return;
if (session?.user.passwordResetRequired && pathname !== '/change-password') {
router.push('/change-password');
}
}, [session, status, router, pathname]);
if (status === 'loading') {
return <div>Loading...</div>;
}
return <>{children}</>;
}
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<SettingsProvider>{children}</SettingsProvider>
<SettingsProvider>
<PasswordResetGuard>{children}</PasswordResetGuard>
</SettingsProvider>
</ThemeProvider>
</SessionProvider>
);

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

187
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -32,6 +32,7 @@ export const authOptions: NextAuthOptions = {
id: user.id.toString(),
email: user.email,
role: user.role,
passwordResetRequired: user.passwordResetRequired,
};
},
}),
@@ -40,9 +41,15 @@ export const authOptions: NextAuthOptions = {
strategy: 'jwt',
},
callbacks: {
async jwt({ token, user }) {
async jwt({ token, user, trigger, session }) {
if (user) {
token.role = user.role;
token.passwordResetRequired = user.passwordResetRequired;
}
if (trigger === 'update' && session) {
if (typeof session.passwordResetRequired === 'boolean') {
token.passwordResetRequired = session.passwordResetRequired;
}
}
return token;
},
@@ -50,6 +57,7 @@ export const authOptions: NextAuthOptions = {
if (token) {
session.user.id = token.sub!;
session.user.role = token.role as string;
session.user.passwordResetRequired = token.passwordResetRequired as boolean;
}
return session;
},

View File

@@ -10,14 +10,17 @@
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"test:e2e": "cypress run",
"ci:run": "pnpm build && npx start-server-and-test start http://localhost:3000 test:e2e"
"ci:run": "pnpm build && npx start-server-and-test start http://localhost:3000 test:e2e",
"db:migrate": "prisma migrate deploy"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "6.17.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@types/bcrypt": "^6.0.0",
"bcrypt": "^6.0.0",
@@ -32,11 +35,12 @@
"next": "16.0.0",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"prisma": "^6.17.1",
"prisma": "6.17.1",
"react": "19.2.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"rimraf": "^6.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",

2915
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MEMBER',
"firstName" TEXT,
"lastName" TEXT,
"passwordResetRequired" BOOLEAN NOT NULL DEFAULT false
);
-- CreateTable
CREATE TABLE "Hour" (
"id" TEXT NOT NULL PRIMARY KEY,
"date" DATETIME NOT NULL,
"duration" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"validatedById" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "Hour_validatedById_fkey" FOREIGN KEY ("validatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Hour_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ClubSettings" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'settings',
"name" TEXT NOT NULL,
"logo" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@@ -0,0 +1,21 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Hour" (
"id" TEXT NOT NULL PRIMARY KEY,
"date" DATETIME NOT NULL,
"duration" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"validatedById" TEXT,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Hour_validatedById_fkey" FOREIGN KEY ("validatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Hour_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Hour" ("date", "duration", "id", "reason", "status", "userId", "validatedById") SELECT "date", "duration", "id", "reason", "status", "userId", "validatedById" FROM "Hour";
DROP TABLE "Hour";
ALTER TABLE "new_Hour" RENAME TO "Hour";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -10,6 +10,7 @@ datasource db {
url = "file:./data/dev.db"
}
model User {
id String @id
email String @unique
@@ -19,6 +20,7 @@ model User {
validatedHours Hour[] @relation("ValidatedHours")
firstName String?
lastName String?
passwordResetRequired Boolean @default(false)
}
model Hour {
@@ -31,6 +33,8 @@ model Hour {
validatedById String?
user User @relation("UserHours", fields: [userId], references: [id])
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
}
model ClubSettings {

View File

@@ -0,0 +1,30 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
console.log('Updating passwords for users requiring reset...');
const hashedPassword = await bcrypt.hash('123456', 10);
const result = await prisma.user.updateMany({
where: {
passwordResetRequired: true,
},
data: {
password: hashedPassword,
},
});
console.log(`Updated ${result.count} users with temporary password "123456"`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -6,16 +6,19 @@ declare module 'next-auth' {
id: string;
email: string;
role: string;
passwordResetRequired?: boolean;
};
}
interface User {
role: string;
passwordResetRequired?: boolean;
}
}
declare module 'next-auth/jwt' {
interface JWT {
role: string;
passwordResetRequired?: boolean;
}
}