mirror of
https://github.com/BreizhHardware/Site-comptage-heure.git
synced 2026-01-18 16:17:28 +01:00
@@ -41,4 +41,4 @@ prisma/dev.db
|
||||
INITIAL_SETUP.md
|
||||
API_DOCS.md
|
||||
INDICATIONS_POUR_LLMS.md
|
||||
|
||||
scripts/seed-test.js
|
||||
|
||||
@@ -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**
|
||||
|
||||
213
app/admin/import-users/page.tsx
Normal file
213
app/admin/import-users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
35
app/api/change-password/route.ts
Normal file
35
app/api/change-password/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
170
app/api/import-users/route.ts
Normal file
170
app/api/import-users/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
117
app/change-password/page.tsx
Normal file
117
app/change-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
56
components/mode-toggle.tsx
Normal file
56
components/mode-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal 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
187
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
10
lib/auth.ts
10
lib/auth.ts
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
2915
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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 {
|
||||
|
||||
30
scripts/fix-imported-passwords.js
Normal file
30
scripts/fix-imported-passwords.js
Normal 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();
|
||||
});
|
||||
3
types/next-auth.d.ts
vendored
3
types/next-auth.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user