diff --git a/backend/users/serializers.py b/backend/users/serializers.py
index 0691dee..8d2c9e2 100644
--- a/backend/users/serializers.py
+++ b/backend/users/serializers.py
@@ -20,12 +20,18 @@ class UserSerializer(serializers.ModelSerializer):
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
+ password_confirm = serializers.CharField(write_only=True)
invite = serializers.UUIDField(write_only=True)
class Meta:
model = User
- fields = ['username', 'password', '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.'})
+ return attrs
def validate_invite(self, value):
try:
@@ -38,6 +44,7 @@ class RegisterSerializer(serializers.ModelSerializer):
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)
if not invitation.is_valid:
diff --git a/frontend/src/App.css b/frontend/src/App.css
index a0e0c14..eb2d237 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1,36 +1,63 @@
#root {
+ width: 100%;
+ min-width: 0;
+ min-height: 100dvh;
+}
+
+.app-shell {
display: flex;
+ width: 100%;
+ min-width: 0;
+ min-height: 100dvh;
flex-direction: column;
+}
+
+.app-shell.app--fixed {
height: 100dvh;
- min-height: 100vh;
+ overflow: hidden;
+}
+
+.app-shell.app--scroll {
+ min-height: 100dvh;
}
main {
+ display: flex;
+ flex-direction: column;
width: min(var(--content-max), 100%);
+ min-width: 0;
min-height: 0;
- flex: 1 1 auto;
+ flex: 1 0 auto;
margin: 0 auto;
- overflow-y: auto;
+ overflow: visible;
padding: 2rem clamp(1.25rem, 3vw, 2.75rem);
}
.page-route {
+ display: flex;
+ flex: 1 0 auto;
+ flex-direction: column;
width: 100%;
+ min-width: 0;
max-width: var(--content-max);
min-height: 100%;
margin: 0 auto;
}
-main.main--bounded {
+main.main--fixed {
+ min-height: 0;
overflow: hidden;
+ flex: 1 1 auto;
}
-main.main--bounded .page-route {
+main.main--fixed .page-route {
height: 100%;
+ min-height: 0;
+ flex: 1 1 auto;
overflow: hidden;
}
-main.main--flow .page-route {
+main.main--scroll .page-route {
min-height: 100%;
}
@@ -39,6 +66,7 @@ main.main--flow .page-route {
.page-route > .page-container,
.page-route > .weekly-schedule,
.page-route > .home-page {
+ flex: 1 0 auto;
width: 100%;
max-width: none;
margin-right: 0;
@@ -47,10 +75,21 @@ main.main--flow .page-route {
.page-container {
width: 100%;
+ min-width: 0;
max-width: var(--content-max);
margin: 0 auto;
}
+.page-layout {
+ display: flex;
+ width: 100%;
+ min-width: 0;
+ max-width: var(--content-max);
+ min-height: 100%;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
.page-panel,
.card {
padding: clamp(1.35rem, 4vw, 2.25rem);
@@ -60,24 +99,49 @@ main.main--flow .page-route {
box-shadow: var(--shadow);
}
-.page-heading {
- margin-bottom: 1.5rem;
+.page-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
}
-.page-heading h2,
-.page-panel > h2,
-.card > h2 {
+.page-header h2 {
margin: 0;
color: var(--text);
font-size: clamp(1.35rem, 3vw, 1.8rem);
letter-spacing: -0.02em;
}
-.page-heading p {
+.page-header p {
margin: 0.5rem 0 0;
color: var(--text-muted);
}
+.page-header--stacked {
+ flex-direction: column;
+}
+
+.page-section {
+ width: 100%;
+}
+
+.page-panel > h2,
+.card > h2 {
+ margin: 0 0 1.5rem;
+ color: var(--text);
+ font-size: clamp(1.35rem, 3vw, 1.8rem);
+ letter-spacing: -0.02em;
+}
+
+.page-state {
+ display: grid;
+ min-height: 240px;
+ place-items: center;
+ padding: 2rem;
+ text-align: center;
+}
+
footer {
flex-shrink: 0;
padding: 1.5rem;
@@ -105,6 +169,8 @@ footer p {
text-align: center;
text-decoration: none;
transition: var(--transition);
+ /* Prevent button from resizing on click */
+ flex-shrink: 0;
}
.btn:focus-visible,
@@ -154,48 +220,12 @@ input:focus-visible {
border-color: var(--danger);
}
-.admin-toolbar {
- display: flex;
- flex-wrap: wrap;
- gap: 0.7rem;
- margin-bottom: 1.25rem;
-}
-
.btn-group {
display: flex;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
gap: 0.45rem;
}
-.invite-result {
- margin-top: 1.5rem;
-}
-
-.invite-result h3,
-.info-panel h3 {
- margin: 0 0 0.75rem;
- color: var(--text);
- font-size: 1rem;
-}
-
-.invite-link-box {
- display: flex;
- align-items: center;
- gap: 0.7rem;
- padding: 0.7rem;
- background: var(--surface-muted);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
-}
-
-.invite-link-input {
- min-width: 0;
- padding: 0.5rem;
- color: var(--text-muted);
- background: transparent;
- border: 0;
-}
-
.helper-text {
margin: 0.7rem 0 0;
color: var(--text-muted);
@@ -332,12 +362,6 @@ tbody tr:hover {
border: 1px solid #c7e5d2;
}
-.loading {
- display: grid;
- min-height: 240px;
- place-items: center;
-}
-
.spinner {
width: 38px;
height: 38px;
@@ -353,6 +377,8 @@ tbody tr:hover {
@media (max-width: 700px) {
main {
+ width: 100%;
+ min-height: 0;
padding: 1.25rem 1rem;
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index aa21d93..987d114 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -10,13 +10,22 @@ import TimeClockPage from './pages/TimeClockPage';
import WeeklySummaryPage from './pages/WeeklySummaryPage';
import './App.css';
+const pageLayout = {
+ '/': { scroll: false },
+ '/login': { scroll: false },
+ '/register': { scroll: false },
+ '/pointage': { scroll: true },
+ '/users': { scroll: false },
+ '/weekly-summary': { scroll: false },
+};
+
function App() {
const { pathname } = useLocation();
- const boundedPages = ['/weekly-summary'];
- const layoutMode = boundedPages.includes(pathname) ? 'bounded' : 'flow';
+ const shouldScroll = pageLayout[pathname]?.scroll ?? true;
+ const layoutMode = shouldScroll ? 'scroll' : 'fixed';
return (
-
+
diff --git a/frontend/src/components/AdminRoute.jsx b/frontend/src/components/AdminRoute.jsx
index 423dfb3..efd5d31 100644
--- a/frontend/src/components/AdminRoute.jsx
+++ b/frontend/src/components/AdminRoute.jsx
@@ -7,7 +7,7 @@ const AdminRoute = ({ children }) => {
const location = useLocation();
if (loading) {
- return
Loading...
;
+ return
Chargement...
;
}
if (!user) {
diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx
index bfbceff..6fd5471 100644
--- a/frontend/src/components/ProtectedRoute.jsx
+++ b/frontend/src/components/ProtectedRoute.jsx
@@ -7,7 +7,7 @@ const ProtectedRoute = ({ children }) => {
const location = useLocation();
if (loading) {
- return
Loading...
;
+ return
Chargement...
;
}
if (!user) {
diff --git a/frontend/src/pages/AdminUsersPage.css b/frontend/src/pages/AdminUsersPage.css
new file mode 100644
index 0000000..b0a1357
--- /dev/null
+++ b/frontend/src/pages/AdminUsersPage.css
@@ -0,0 +1,43 @@
+.invite-result h3,
+.info-panel h3 {
+ margin: 0 0 0.75rem;
+ color: var(--text);
+ font-size: 1rem;
+}
+
+.invite-link-input {
+ flex: 1 1 auto;
+ width: auto;
+ min-width: 0;
+ color: var(--text-muted);
+ background: transparent;
+ border: 0;
+ padding: 0.7rem 0.8rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border);
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.invite-actions {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 0.7rem;
+}
+
+@media (max-width: 600px) {
+ .invite-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .invite-actions .btn,
+ .invite-link-input {
+ width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx
index e3b9630..a496c63 100644
--- a/frontend/src/pages/AdminUsersPage.jsx
+++ b/frontend/src/pages/AdminUsersPage.jsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { userApi } from '../api/users';
-
+import './AdminUsersPage.css';
const AdminUsersPage = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -82,44 +82,24 @@ const AdminUsersPage = () => {
if (loading) {
return (
-
-
Gestion des comptes
+
+
Chargement des utilisateurs...
);
}
return (
-
-
Gestion des comptes
- {error &&
{error}
}
-
-
-
- {inviteLink && (
-
-
-
-
- )}
-
+
+
-
+ {error &&
{error}
}
+
+
@@ -163,7 +143,42 @@ const AdminUsersPage = () => {
))}
-
+
+
+
+
);
};
diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx
index 04c88d6..22e20dc 100644
--- a/frontend/src/pages/HomePage.jsx
+++ b/frontend/src/pages/HomePage.jsx
@@ -19,16 +19,18 @@ const HomePage = () => {
if (loading) {
return (
-
+
);
}
return (
-
-
+
+
+
{userData && (
@@ -45,7 +47,7 @@ const HomePage = () => {
)}
-
+
);
};
diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx
index 42c81af..f7ec26a 100644
--- a/frontend/src/pages/LoginPage.jsx
+++ b/frontend/src/pages/LoginPage.jsx
@@ -26,11 +26,14 @@ const LoginPage = () => {
};
return (
-
+
-
Connexion
+
{error &&
{error}
}
-
);
diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx
index bfd88f9..e7097b5 100644
--- a/frontend/src/pages/RegisterPage.jsx
+++ b/frontend/src/pages/RegisterPage.jsx
@@ -6,7 +6,13 @@ import './LoginPage.css';
const RegisterPage = () => {
const invite = new URLSearchParams(useLocation().search).get('invite') || '';
const navigate = useNavigate();
- const [form, setForm] = useState({ username: '', password: '' });
+ const [form, setForm] = useState({
+ first_name: '',
+ last_name: '',
+ username: '',
+ password: '',
+ password_confirm: '',
+ });
const [error, setError] = useState('');
const [isCreated, setIsCreated] = useState(false);
@@ -18,28 +24,62 @@ const RegisterPage = () => {
setIsCreated(true);
} catch (requestError) {
const detail = requestError.response?.data;
- setError(detail?.invite?.[0] || detail?.username?.[0] || 'Impossible de créer le compte.');
+ setError(
+ detail?.invite?.[0]
+ || detail?.first_name?.[0]
+ || detail?.last_name?.[0]
+ || detail?.username?.[0]
+ || detail?.password?.[0]
+ || detail?.password_confirm?.[0]
+ || 'Impossible de créer le compte.',
+ );
}
};
if (!invite) {
- return
Lien invalide
Un lien d’invitation est nécessaire pour créer un compte.
;
+ return (
+
+
+
+
Un lien d’invitation est nécessaire pour créer un compte.
+
+
+ );
}
if (isCreated) {
- return
Compte créé
Votre compte est prêt. Vous pouvez maintenant vous connecter.
;
+ return (
+
+
+
+
Votre compte est prêt. Vous pouvez maintenant vous connecter.
+
+
+
+ );
}
return (
-
+
-
Créer un compte
+
{error &&
{error}
}
-
+
);
diff --git a/frontend/src/pages/TimeClockPage.css b/frontend/src/pages/TimeClockPage.css
index fb71e47..b6fd203 100644
--- a/frontend/src/pages/TimeClockPage.css
+++ b/frontend/src/pages/TimeClockPage.css
@@ -111,6 +111,17 @@
margin: 0 0 1rem;
}
+.form-group--inline {
+ display: grid;
+ grid-template-columns: minmax(130px, 0.35fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 1rem;
+}
+
+.form-group--inline label {
+ margin: 0;
+}
+
.time-entries-list {
margin: 1.25rem 0;
}
@@ -159,4 +170,11 @@
grid-column: auto;
grid-row: auto;
}
+}
+
+@media (max-width: 520px) {
+ .form-group--inline {
+ grid-template-columns: 1fr;
+ gap: 0.35rem;
+ }
}
\ No newline at end of file
diff --git a/frontend/src/pages/TimeClockPage.jsx b/frontend/src/pages/TimeClockPage.jsx
index 7968a31..5d67b37 100644
--- a/frontend/src/pages/TimeClockPage.jsx
+++ b/frontend/src/pages/TimeClockPage.jsx
@@ -321,12 +321,10 @@ const TimeClockPage = () => {
};
return (
-
-
-
Présence
+
+
{error &&
{error}
}
@@ -367,7 +365,7 @@ const TimeClockPage = () => {
)}
{/* Time tracking section */}
-
+
{isAdmin && (
@@ -375,7 +373,7 @@ const TimeClockPage = () => {
Modifier le pointage du {formatDate(editingEntry.date)}