diff --git a/backend/core/settings.py b/backend/core/settings.py
index 3d832d6..9e95823 100644
--- a/backend/core/settings.py
+++ b/backend/core/settings.py
@@ -105,6 +105,7 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
+AUTH_USER_MODEL = 'users.User'
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
@@ -154,4 +155,3 @@ SIMPLE_JWT = {
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
-DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/backend/time_tracking/models.py b/backend/time_tracking/models.py
index 7836b2d..d85ed99 100644
--- a/backend/time_tracking/models.py
+++ b/backend/time_tracking/models.py
@@ -1,9 +1,9 @@
from django.db import models
-from django.contrib.auth.models import User
+from django.conf import settings
from datetime import datetime, date
class TimeEntry(models.Model):
- user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='time_entries')
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='time_entries')
date = models.DateField()
start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True)
diff --git a/backend/time_tracking/serializers.py b/backend/time_tracking/serializers.py
index 90b5823..495dc69 100644
--- a/backend/time_tracking/serializers.py
+++ b/backend/time_tracking/serializers.py
@@ -3,14 +3,20 @@ from django.contrib.auth.models import User
from .models import TimeEntry
class TimeEntrySerializer(serializers.ModelSerializer):
- user = serializers.StringRelatedField(read_only=True)
- username = serializers.CharField(source='user.username', read_only=True)
-
class Meta:
model = TimeEntry
- fields = ['id', 'user', 'username', 'date', 'start_time', 'end_time', 'duration', 'created_at']
- read_only_fields = ['user', 'duration', 'created_at']
+ fields = '__all__'
+ read_only_fields = ('user',)
+ def create(self, validated_data):
+ # Set the user to the current request user
+ validated_data['user'] = self.context['request'].user
+ return super().create(validated_data)
+
+ def update(self, instance, validated_data):
+ # Prevent updating the user field
+ validated_data.pop('user', None)
+ return super().update(instance, validated_data)
class TimeEntryCreateSerializer(serializers.ModelSerializer):
class Meta:
model = TimeEntry
@@ -36,22 +42,7 @@ class TimeEntryUpdateSerializer(serializers.ModelSerializer):
return attrs
class WeeklySummarySerializer(serializers.Serializer):
- user = serializers.StringRelatedField()
+ user = serializers.CharField()
username = serializers.CharField()
total_hours = serializers.FloatField()
- days = serializers.ListField()
-
- def to_representation(self, instance):
- data = super().to_representation(instance)
- # Format the days data for frontend consumption
- formatted_days = []
- for day in data['days']:
- formatted_days.append({
- 'date': day['date'],
- 'day': day['day'],
- 'start_time': day['start_time'],
- 'end_time': day['end_time'],
- 'duration': day['duration']
- })
- data['days'] = formatted_days
- return data
\ No newline at end of file
+ entries = serializers.ListField(child=serializers.DictField())
diff --git a/backend/users/apps.py b/backend/users/apps.py
index 2b447c9..4b14488 100644
--- a/backend/users/apps.py
+++ b/backend/users/apps.py
@@ -14,26 +14,42 @@ class UsersConfig(AppConfig):
if 'runserver' in sys.argv:
try:
# Import here to avoid issues during Django startup
- from django.contrib.auth.models import User
+ from .models import User
from decouple import config
from django.db import transaction
- # Check if any users exist
- if not User.objects.exists():
- # Get credentials from environment variables or use defaults
- username = config('ADMIN_USERNAME', default='admin')
- password = config('ADMIN_PASSWORD', default='admin123')
- email = config('ADMIN_EMAIL', default='admin@boulangerie.fr')
-
- # Create the admin user
- with transaction.atomic():
- user = User.objects.create_superuser(
- username=username,
- email=email,
- password=password
- )
-
- print(f'Successfully created admin user "{username}"')
+ username = config('ADMIN_USERNAME', default='').strip()
+ password = config('ADMIN_PASSWORD', default='')
+ first_name = config('ADMIN_FIRST_NAME', default='').strip()
+ last_name = config('ADMIN_LAST_NAME', default='').strip()
+
+ if not username or not password:
+ print('ADMIN_USERNAME and ADMIN_PASSWORD are required; no admin was created.')
+ return
+
+ with transaction.atomic():
+ user, created = User.objects.get_or_create(
+ username=username,
+ defaults={
+ 'first_name': first_name,
+ 'last_name': last_name,
+ 'is_staff': True,
+ 'is_superuser': True,
+ },
+ )
+
+ if created:
+ user.set_password(password)
+ else:
+ user.first_name = first_name
+ user.last_name = last_name
+ user.is_staff = True
+ user.is_superuser = True
+
+ user.save()
+
+ action = 'created' if created else 'synchronized'
+ print(f'Admin user "{username}" {action}.')
except Exception as e:
# Don't fail the entire Django startup if there's an error
print(f'Error creating admin user: {e}')
diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py
index 1ca49d3..7812583 100644
--- a/backend/users/migrations/0001_initial.py
+++ b/backend/users/migrations/0001_initial.py
@@ -1,6 +1,9 @@
-# Generated by Django 5.2.17 on 2026-09-16 16:22
+# Generated by Django 5.2.17 on 2026-09-18 15:41
+import django.contrib.auth.models
+import django.contrib.auth.validators
import django.db.models.deletion
+import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
@@ -11,10 +14,37 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('color', models.CharField(default='#0000ff', max_length=7)),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'abstract': False,
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
migrations.CreateModel(
name='Invitation',
fields=[
diff --git a/backend/users/models.py b/backend/users/models.py
index b56459f..628270c 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -1,17 +1,43 @@
-from django.db import models
-from django.contrib.auth.models import User
import uuid
-# Create your models here.
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+
+
+class User(AbstractUser):
+ color = models.CharField(
+ max_length=7,
+ default='#0000ff',
+ )
+
class Invitation(models.Model):
- token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
- created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_invitations')
+ token = models.UUIDField(
+ default=uuid.uuid4,
+ unique=True,
+ editable=False,
+ )
+
+ created_by = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name='created_invitations',
+ )
+
created_at = models.DateTimeField(auto_now_add=True)
+
expires_at = models.DateTimeField()
- used_at = models.DateTimeField(null=True, blank=True)
+
+ used_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ )
@property
def is_valid(self):
from django.utils import timezone
- return self.used_at is None and self.expires_at > timezone.now()
+
+ return (
+ self.used_at is None
+ and self.expires_at > timezone.now()
+ )
\ No newline at end of file
diff --git a/backend/users/serializers.py b/backend/users/serializers.py
index 8d2c9e2..470120d 100644
--- a/backend/users/serializers.py
+++ b/backend/users/serializers.py
@@ -1,4 +1,4 @@
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from rest_framework import serializers
@@ -6,13 +6,28 @@ from rest_framework import serializers
from .models import Invitation
+User = get_user_model()
+
+
class UserSerializer(serializers.ModelSerializer):
is_admin = serializers.SerializerMethodField()
class Meta:
model = User
-
- fields = ['id', 'username', 'first_name', 'last_name', 'is_admin', 'date_joined']
+ fields = [
+ 'id',
+ 'username',
+ 'first_name',
+ 'last_name',
+ 'email',
+ 'is_active',
+ 'is_staff',
+ 'is_superuser',
+ 'is_admin',
+ 'date_joined',
+ 'color',
+ ]
+ read_only_fields = ['id', 'date_joined', 'is_admin']
def get_is_admin(self, obj):
return obj.is_superuser or obj.is_staff
@@ -25,31 +40,61 @@ class RegisterSerializer(serializers.ModelSerializer):
class Meta:
model = User
-
- fields = ['username', 'password', 'password_confirm', 'first_name', 'last_name', 'invite']
+ fields = [
+ 'username',
+ 'password',
+ 'password_confirm',
+ 'first_name',
+ 'last_name',
+ 'invite',
+ ]
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
- raise serializers.ValidationError({'password_confirm': 'Les mots de passe ne correspondent pas.'})
+ raise serializers.ValidationError({
+ 'password_confirm': (
+ 'Les mots de passe ne correspondent pas.'
+ )
+ })
+
return attrs
def validate_invite(self, value):
try:
invitation = Invitation.objects.get(token=value)
except Invitation.DoesNotExist as error:
- raise serializers.ValidationError('Ce lien d’invitation est invalide.') from error
+ raise serializers.ValidationError(
+ 'Ce lien d’invitation est invalide.'
+ ) from error
+
if not invitation.is_valid:
- raise serializers.ValidationError('Ce lien d’invitation est expire ou deja utilise.')
+ raise serializers.ValidationError(
+ 'Ce lien d’invitation est expiré ou déjà utilisé.'
+ )
+
return value
def create(self, validated_data):
invitation_token = validated_data.pop('invite')
validated_data.pop('password_confirm')
+
with transaction.atomic():
- invitation = Invitation.objects.select_for_update().get(token=invitation_token)
+ invitation = (
+ Invitation.objects
+ .select_for_update()
+ .get(token=invitation_token)
+ )
+
if not invitation.is_valid:
- raise serializers.ValidationError({'invite': 'Ce lien d’invitation est expire ou deja utilise.'})
+ raise serializers.ValidationError({
+ 'invite': (
+ 'Ce lien d’invitation est expiré ou déjà utilisé.'
+ )
+ })
+
user = User.objects.create_user(**validated_data)
+
invitation.used_at = timezone.now()
invitation.save(update_fields=['used_at'])
- return user
+
+ return user
\ No newline at end of file
diff --git a/backend/users/views.py b/backend/users/views.py
index 9bd4dd5..e0a940f 100644
--- a/backend/users/views.py
+++ b/backend/users/views.py
@@ -5,8 +5,7 @@ from rest_framework.views import APIView
from rest_framework.exceptions import PermissionDenied
from django.utils import timezone
from datetime import timedelta
-from django.contrib.auth.models import User
-from .models import Invitation
+from .models import Invitation, User
from .serializers import UserSerializer, RegisterSerializer
class RegisterView(generics.CreateAPIView):
@@ -44,20 +43,10 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
return User.objects.all()
def update(self, request, *args, **kwargs):
- # For updating user permissions, we need to handle is_staff and is_superuser specially
if not (request.user.is_staff or request.user.is_superuser):
raise PermissionDenied('Seuls les administrateurs peuvent modifier les comptes.')
-
- user = self.get_object()
- # Only allow updating specific fields
- allowed_fields = ['is_staff', 'is_superuser']
- for field in allowed_fields:
- if field in request.data:
- setattr(user, field, request.data[field])
-
- user.save()
- serializer = self.get_serializer(user)
- return Response(serializer.data)
+
+ return super().update(request, *args, **kwargs)
class UserPromoteView(APIView):
@@ -109,3 +98,4 @@ class InvitationCreateView(APIView):
'token': str(invitation.token),
'expires_at': invitation.expires_at,
}, status=201)
+
diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js
index 9af5e17..197ac96 100644
--- a/frontend/src/api/users.js
+++ b/frontend/src/api/users.js
@@ -32,6 +32,15 @@ export const userApi = {
}
},
+ updateUser: async (id, data) => {
+ try {
+ const response = await api.patch(`/users/${id}/`, data);
+ return response.data;
+ } catch (error) {
+ throw new Error('Failed to update user: ' + error.message);
+ }
+ },
+
createInvitation: async () => {
try {
const response = await api.post('/users/invitations/');
diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx
index 9b99703..562bca9 100644
--- a/frontend/src/pages/AdminUsersPage.jsx
+++ b/frontend/src/pages/AdminUsersPage.jsx
@@ -13,7 +13,10 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
+import { Checkbox } from '@/components/ui/checkbox';
import ConfirmDialog from '@/components/ConfirmDialog';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
const AdminUsersPage = () => {
const [users, setUsers] = useState([]);
@@ -21,6 +24,17 @@ const AdminUsersPage = () => {
const [error, setError] = useState('');
const [inviteLink, setInviteLink] = useState('');
const [isCopied, setIsCopied] = useState(false);
+ const [editingUser, setEditingUser] = useState(null);
+ const [editForm, setEditForm] = useState({
+ username: '',
+ first_name: '',
+ last_name: '',
+ email: '',
+ color: '#0000ff',
+ is_active: true,
+ is_staff: false,
+ is_superuser: false,
+ });
const generateInviteLink = async () => {
try {
@@ -106,6 +120,35 @@ const AdminUsersPage = () => {
}
};
+ const handleEditUser = async (userId) => {
+ if (!/^#[0-9A-Fa-f]{6}$/.test(editForm.color)) {
+ setError('La couleur doit être au format hexadécimal, par exemple #2563eb.');
+ return;
+ }
+
+ try {
+ const updatedUser = await userApi.updateUser(userId, editForm);
+ const updatedUsers = users.map(user =>
+ user.id === userId ? updatedUser : user
+ );
+ setUsers(updatedUsers);
+ setEditingUser(null);
+ setEditForm({
+ username: '',
+ first_name: '',
+ last_name: '',
+ email: '',
+ color: '#0000ff',
+ is_active: true,
+ is_staff: false,
+ is_superuser: false,
+ });
+ } catch (err) {
+ setError('Erreur lors de la mise à jour de l\'utilisateur');
+ console.error(err);
+ }
+ };
+
if (loading) {
return (