feat: Add bulk import from ISEN excel

This commit is contained in:
2025-11-20 18:00:29 +01:00
parent 1ce9055491
commit eb8132b20f
15 changed files with 1397 additions and 2017 deletions

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

@@ -373,7 +373,12 @@ 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>

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

@@ -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

@@ -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

@@ -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

@@ -32,11 +32,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",

2756
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,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 {

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;
}
}