mirror of
https://github.com/BreizhHardware/Site-comptage-heure.git
synced 2026-01-18 16:17:28 +01:00
feat: Add bulk import from ISEN excel
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -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
2756
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");
|
||||
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 {
|
||||
|
||||
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