diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 34718a3..10b2e57 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -10,6 +10,7 @@ html { color: black; } +#ChangePasswordMap, #loginApp { display: flex; flex-direction: column; @@ -256,6 +257,27 @@ footer { padding: 10px; } +.error { + color: #D8000C; + background-color: #FFD2D2; + padding: 10px; + margin-top: 10px; + margin-bottom: 10px; + border: 1px solid #D8000C; + border-radius: 5px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.success { + color: #155724; + background-color: #d4edda; + border-color: #c3e6cb; + padding: 0.75rem 1.25rem; + margin-top: 1rem; + border: 1px solid transparent; + border-radius: 0.25rem; +} @media (max-width: 768px) { #filters { diff --git a/frontend/src/components/ChangePasswordPage.vue b/frontend/src/components/ChangePasswordPage.vue new file mode 100644 index 0000000..c61cdda --- /dev/null +++ b/frontend/src/components/ChangePasswordPage.vue @@ -0,0 +1,56 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/LoginPage.vue b/frontend/src/components/LoginPage.vue index f7cba90..f0243e0 100644 --- a/frontend/src/components/LoginPage.vue +++ b/frontend/src/components/LoginPage.vue @@ -6,7 +6,7 @@ -

{{ loginError }}

+

{{ error }}

@@ -14,7 +14,7 @@ diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index c40fd58..9b6764b 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,7 +1,8 @@ +import { useVespaStore } from '@/stores/vespaStore'; import { createRouter, createWebHistory } from 'vue-router'; +import ChangePasswordPage from '../components/ChangePasswordPage.vue'; import Login from '../components/LoginPage.vue'; import MapPage from '../components/MapPage.vue'; - const routes = [ { path: '/login', @@ -17,6 +18,11 @@ const routes = [ path: '/', name: 'Home', component: MapPage + }, + { + path: '/change-password', + name: 'ChangePassword', + component: ChangePasswordPage } ]; @@ -26,6 +32,11 @@ const router = createRouter({ }); router.beforeEach(async (to, from, next) => { - next(); + const vespaStore = useVespaStore(); + if (to.meta.requiresAuth && !vespaStore.isLoggedIn) { + next({ name: 'Login' }); + } else { + next(); + } }); export default router; diff --git a/frontend/src/stores/vespaStore.js b/frontend/src/stores/vespaStore.js index 0c976a8..64b9ce8 100644 --- a/frontend/src/stores/vespaStore.js +++ b/frontend/src/stores/vespaStore.js @@ -7,7 +7,6 @@ import { defineStore } from 'pinia'; export const useVespaStore = defineStore('vespaStore', { state: () => ({ isLoggedIn: false, - username: '', userId: null, loading: false, error: null, @@ -142,6 +141,7 @@ export const useVespaStore = defineStore('vespaStore', { password: password }) .then(() => { + this.isLoggedIn = true; this.authCheck(); }) .catch((error) => { @@ -190,9 +190,13 @@ export const useVespaStore = defineStore('vespaStore', { async logout() { this.loading = true; await ApiService - .get("/api-auth/logout/") + .post("/app_auth/logout/") .then(() => { - this.authCheck(); + this.isLoggedIn = false; + this.user = {}; + this.loading = false; + console.log('Logged out successfully'); + this.router.push({ name: 'map' }); }) .catch((error) => { console.error(error.response.data); @@ -200,5 +204,36 @@ export const useVespaStore = defineStore('vespaStore', { this.loading = false; }); }, + async changePassword(oldPassword, newPassword, confirmPassword) { + this.loading = true; + if (!oldPassword || !newPassword) { + this.error = "Vul aub alle velden in."; + this.loading = false; + return false; + } + if (newPassword !== confirmPassword) { + this.error = "De wachtwoorden komen niet overeen."; + this.loading = false; + return false; + } + try { + await ApiService.post("/app_auth/change-password/", { + old_password: oldPassword, + new_password: newPassword, + }); + this.loading = false; + this.error = null; + return true; + } catch (error) { + this.loading = false; + if (error.response && error.response.data) { + const backendMessages = error.response.data; + this.error = backendMessages.detail || "Een onverwachte fout is opgetreden."; + } else { + this.error = error.message || "Een onverwachte fout is opgetreden."; + } + return false; + } + }, }, }); diff --git a/vespadb/app_auth/serializers.py b/vespadb/app_auth/serializers.py index 5b1622b..9508fc2 100644 --- a/vespadb/app_auth/serializers.py +++ b/vespadb/app_auth/serializers.py @@ -62,3 +62,10 @@ def validate(self, data: dict[Any, Any]) -> User: if user and user.check_password(data["password"]): return user raise ValidationError({"error": "Invalid username or password."}) + + +class ChangePasswordSerializer(serializers.Serializer): + """Validate user input for changing password.""" + + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True) diff --git a/vespadb/app_auth/urls.py b/vespadb/app_auth/urls.py index 6bdf6bc..458d9ba 100644 --- a/vespadb/app_auth/urls.py +++ b/vespadb/app_auth/urls.py @@ -2,10 +2,12 @@ from django.urls import path -from vespadb.app_auth.views import AuthCheck, LoginView, expire_session_view +from vespadb.app_auth.views import AuthCheck, ChangePasswordView, LoginView, LogoutView, expire_session_view urlpatterns = [ path("auth-check", AuthCheck.as_view(), name="auth_check"), path("login/", LoginView.as_view(), name="login"), + path("logout/", LogoutView.as_view(), name="logout"), + path("change-password/", ChangePasswordView.as_view(), name="change_password"), # New change password endpoint path("expire-session//", expire_session_view, name="expire_session"), ] diff --git a/vespadb/app_auth/views.py b/vespadb/app_auth/views.py index 4033946..998a1de 100644 --- a/vespadb/app_auth/views.py +++ b/vespadb/app_auth/views.py @@ -1,8 +1,9 @@ """views for the app_auth app.""" from collections.abc import Sequence +from typing import Any -from django.contrib.auth import login +from django.contrib.auth import login, logout, update_session_auth_hash from django.contrib.sessions.models import Session from django.http import HttpResponseRedirect from rest_framework import permissions, status @@ -12,7 +13,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from vespadb.app_auth.serializers import LoginSerializer, UserSerializer +from vespadb.app_auth.serializers import ChangePasswordSerializer, LoginSerializer, UserSerializer class AuthCheck(APIView): @@ -64,6 +65,32 @@ def post(self, request: Request) -> Response: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class LogoutView(APIView): + """API view for user logout.""" + + def post(self, request: Request) -> Response: + """Logout a user.""" + logout(request) + return Response({"detail": "Successfully logged out."}, status=status.HTTP_200_OK) + + +class ChangePasswordView(APIView): + """Change password view.""" + + def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """.""" + serializer = ChangePasswordSerializer(data=request.data) + if serializer.is_valid(): + user = request.user + if not user.check_password(serializer.validated_data["old_password"]): + return Response({"old_password": ["Wrong password."]}, status=status.HTTP_400_BAD_REQUEST) + user.set_password(serializer.validated_data["new_password"]) + user.save() + update_session_auth_hash(request, user) # Important to keep the session active + return Response({"detail": "Password changed successfully."}, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def expire_session_view(request: Request, session_key: str) -> HttpResponseRedirect: """ View to expire a specific session. diff --git a/vespadb/settings.py b/vespadb/settings.py index 16d6df8..c5f0c7d 100644 --- a/vespadb/settings.py +++ b/vespadb/settings.py @@ -57,6 +57,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -75,7 +76,7 @@ ], "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), } -CSRF_COOKIE_NAME = "csrftoken" + SESSION_COOKIE_AGE = 60 * 60 SESSION_SAVE_EVERY_REQUEST = True diff --git a/vespadb/urls.py b/vespadb/urls.py index 30b8553..4f86c29 100644 --- a/vespadb/urls.py +++ b/vespadb/urls.py @@ -8,8 +8,6 @@ from drf_yasg.views import get_schema_view from rest_framework import permissions -from vespadb.users.views import UserStatusView - schema_view = get_schema_view( openapi.Info( title="Vespawatch API Documentation", @@ -28,9 +26,7 @@ path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), path("admin/", admin.site.urls), # User views - path("user_status/", UserStatusView.as_view(), name="user_status"), path("app_auth/", include("vespadb.app_auth.urls")), - path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), path("admin/", admin.site.urls), # Include the observations app URLs path("", include("vespadb.observations.urls", namespace="observations")), diff --git a/vespadb/users/views.py b/vespadb/users/views.py index 3deab1c..23edfa2 100644 --- a/vespadb/users/views.py +++ b/vespadb/users/views.py @@ -1,14 +1,8 @@ """Views for the users app.""" from django.contrib.auth.models import User -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError as DjangoValidationError -from rest_framework import serializers, status, viewsets -from rest_framework.decorators import action +from rest_framework import serializers, viewsets from rest_framework.permissions import AllowAny, BasePermission, IsAdminUser -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView from vespadb.permissions import IsAdminOrSelf from vespadb.users.serializers import UserSerializer @@ -34,41 +28,3 @@ def get_permissions(self) -> list[BasePermission]: else: permission_classes = [AllowAny] return [permission() for permission in permission_classes] - - @action(detail=True, methods=["post"]) - def change_password(self, request: Request, pk: int | None = None) -> Response: - """Change the password of the user.""" - user: User = self.get_object() - if user != request.user: - return Response({"message": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN) - - old_password = request.data.get("old_password") - new_password = request.data.get("new_password") - confirm_new_password = request.data.get("confirm_new_password") - - if not user.check_password(old_password): - return Response({"message": "Old password is incorrect."}, status=status.HTTP_400_BAD_REQUEST) - - if new_password != confirm_new_password: - return Response({"message": "New passwords do not match."}, status=status.HTTP_400_BAD_REQUEST) - - try: - validate_password(new_password, user) - user.set_password(new_password) - user.save() - return Response({"message": "Password changed successfully."}) - except DjangoValidationError as e: - return Response({"error": list(e.messages)}, status=status.HTTP_400_BAD_REQUEST) - - -class UserStatusView(APIView): - """View to check if a user is logged in.""" - - permission_classes = [AllowAny] - - def get(self, request: Request) -> Response: - """Return user status.""" - user: User = request.user - if user.is_authenticated: - return Response({"is_logged_in": True, "username": user.username, "user_id": user.id}) - return Response({"is_logged_in": False}, status=status.HTTP_401_UNAUTHORIZED)