diff --git a/backend/account/migrations/0004_customuser_pending_email.py b/backend/account/migrations/0004_customuser_pending_email.py new file mode 100644 index 0000000..cdc8f9c --- /dev/null +++ b/backend/account/migrations/0004_customuser_pending_email.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_customuser_banner'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='pending_email', + field=models.EmailField(blank=True, null=True, max_length=254), + ), + ] diff --git a/backend/account/models.py b/backend/account/models.py index 88de28f..c2b7dbe 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -58,6 +58,7 @@ class CustomUser(SoftDeleteModel, AbstractUser): email_verified = models.BooleanField(default=False) email = models.EmailField(unique=True, db_index=True) + pending_email = models.EmailField(null=True, blank=True) # + fields for email verification flow email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True) @@ -116,7 +117,7 @@ class CustomUser(SoftDeleteModel, AbstractUser): return super().delete(*args, **kwargs) - _NULLABLE_CHAR_FIELDS = ('phone_number', 'city', 'street', 'country', 'postal_code', 'email_verification_token') + _NULLABLE_CHAR_FIELDS = ('phone_number', 'city', 'street', 'country', 'postal_code', 'email_verification_token', 'pending_email') def save(self, *args, **kwargs): for field in self._NULLABLE_CHAR_FIELDS: diff --git a/backend/account/serializers.py b/backend/account/serializers.py index c3100e8..648aeaf 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -217,6 +217,31 @@ class PasswordResetConfirmSerializer(serializers.Serializer): return value +class ChangeEmailSerializer(serializers.Serializer): + current_password = serializers.CharField(write_only=True) + new_email = serializers.EmailField() + turnstile_token = serializers.CharField(write_only=True, required=False, allow_blank=True) + + def validate_new_email(self, value): + value = value.lower() + if User.objects.filter(email__iexact=value).exists(): + raise serializers.ValidationError("Tento e-mail je již používán.") + return value + + +class ChangeUsernameSerializer(serializers.Serializer): + new_username = serializers.CharField(min_length=3, max_length=150) + turnstile_token = serializers.CharField(write_only=True, required=False, allow_blank=True) + + def validate_new_username(self, value): + import re + if not re.match(r'^[\w.@+-]+$', value): + raise serializers.ValidationError("Povolena jsou písmena, číslice a znaky @/./+/-/_.") + if User.objects.filter(username__iexact=value).exists(): + raise serializers.ValidationError("Toto uživatelské jméno je již obsazeno.") + return value + + class ChangePasswordSerializer(serializers.Serializer): current_password = serializers.CharField(write_only=True) new_password = serializers.CharField(write_only=True) diff --git a/backend/account/tasks.py b/backend/account/tasks.py index f01d7cd..b9ab36e 100644 --- a/backend/account/tasks.py +++ b/backend/account/tasks.py @@ -39,6 +39,50 @@ def send_email_verification_task(user_id): ) +@shared_task +def send_email_change_verification_task(user_id, new_email, verify_url): + """Sends confirmation link to the NEW email address.""" + from notifications.tasks import send_email_with_context + try: + user = CustomUser.objects.get(pk=user_id) + except CustomUser.DoesNotExist: + return + send_email_with_context( + recipients=new_email, + subject="Potvrzení změny e-mailu — vontor.cz", + template_path="email/action_confirm.html", + context={ + "title": "Potvrďte novou e-mailovou adresu", + "description": f"Obdrželi jsme žádost o změnu e-mailové adresy na účtu {user.username}. Klikněte na tlačítko níže pro potvrzení.", + "action_url": verify_url, + "cta_label": "Potvrdit nový e-mail", + "note": "Pokud jste tuto změnu nepožadovali, ignorujte tento e-mail — vaše stávající adresa zůstane aktivní.", + }, + ) + + +@shared_task +def send_email_change_notification_task(user_id, old_email, new_email): + """Notifies the OLD email address that a change was requested.""" + from notifications.tasks import send_email_with_context + try: + user = CustomUser.objects.get(pk=user_id) + except CustomUser.DoesNotExist: + return + send_email_with_context( + recipients=old_email, + subject="Žádost o změnu e-mailu — vontor.cz", + template_path="email/action_confirm.html", + context={ + "title": "Vaše e-mailová adresa se mění", + "description": f"Na účtu {user.username} byla zahájena změna e-mailové adresy na {new_email}.", + "action_url": None, + "cta_label": None, + "note": "Pokud jste tuto akci nespustili, okamžitě kontaktujte podporu. Změna vstoupí v platnost až po potvrzení z nové adresy.", + }, + ) + + @shared_task def send_email_test_task(email): send_email_with_context( diff --git a/backend/account/urls.py b/backend/account/urls.py index 3bdab45..0c49ca9 100644 --- a/backend/account/urls.py +++ b/backend/account/urls.py @@ -16,6 +16,15 @@ urlpatterns = [ # Registration & email endpoints path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'), path('verify-email///', views.EmailVerificationView.as_view(), name='verify-email'), + path('resend-verification/', views.ResendEmailVerificationView.as_view(), name='resend-email-verification'), + + # Account deletion + path('delete/', views.DeleteAccountView.as_view(), name='delete-account'), + + # Email & username change + path('change-email/', views.ChangeEmailView.as_view(), name='change-email'), + path('confirm-email-change///', views.ConfirmEmailChangeView.as_view(), name='confirm-email-change'), + path('change-username/', views.ChangeUsernameView.as_view(), name='change-username'), # Password reset endpoints path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'), diff --git a/backend/account/views.py b/backend/account/views.py index a97c33e..2596ec9 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -6,7 +6,10 @@ from .serializers import * from .permissions import * from .models import CustomUser from .tokens import * -from .tasks import send_password_reset_email_task, send_email_verification_task +from .tasks import ( + send_password_reset_email_task, send_email_verification_task, + send_email_change_verification_task, send_email_change_notification_task, +) from django.conf import settings import logging logger = logging.getLogger(__name__) @@ -431,6 +434,208 @@ class PasswordResetConfirmView(APIView): user.save() return Response({"detail": "Heslo bylo úspěšně změněno."}) return Response(serializer.errors, status=400) - + + +@extend_schema( + tags=["account"], + summary="Resend email verification", + description="Resends the verification email to the currently authenticated user. Limited to once per minute.", + responses={ + 200: OpenApiResponse(description="Verification email sent."), + 400: OpenApiResponse(description="Email is already verified."), + 429: OpenApiResponse(description="Rate limited — seconds_remaining returned."), + }, +) +class ResendEmailVerificationView(APIView): + permission_classes = [IsAuthenticated] + COOLDOWN = 60 # seconds + + def post(self, request): + from django.core.cache import cache + import time + + user = request.user + if user.email_verified: + return Response({"detail": "E-mail je již ověřen."}, status=status.HTTP_400_BAD_REQUEST) + + cache_key = f"resend_verification_{user.id}" + sent_at = cache.get(cache_key) + if sent_at is not None: + remaining = int(self.COOLDOWN - (time.time() - sent_at)) + if remaining > 0: + return Response( + {"detail": "Příliš mnoho pokusů.", "seconds_remaining": remaining}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + cache.set(cache_key, time.time(), timeout=self.COOLDOWN) + try: + send_email_verification_task.delay(user.id) + except Exception as e: + logger.error(f"Celery not available, using fallback. Error: {e}") + send_email_verification_task(user.id) + return Response({"detail": "Ověřovací e-mail byl odeslán."}) + + +@extend_schema( + tags=["account"], + summary="Request email address change", + description="Validates current password + Turnstile, stores pending_email, sends confirmation link to the new address and a notification to the old one.", + responses={ + 200: OpenApiResponse(description="Confirmation email sent to new address."), + 400: OpenApiResponse(description="Wrong password, duplicate email, or failed CAPTCHA."), + }, +) +class ChangeEmailView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + turnstile_token = request.data.get("turnstile_token", "") + remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")) + if not verify_turnstile(turnstile_token, remote_ip): + return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST) + + serializer = ChangeEmailSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = request.user + if not user.check_password(serializer.validated_data["current_password"]): + return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST) + + new_email = serializer.validated_data["new_email"] + old_email = user.email + + user.pending_email = new_email + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = user.generate_email_verification_token(save=False) + user.save(update_fields=["pending_email", "email_verification_token", "email_verification_sent_at"]) + + verify_url = f"{settings.FRONTEND_URL}/account/confirm-email-change/{uid}/{token}/" + + try: + send_email_change_verification_task.delay(user.id, new_email, verify_url) + send_email_change_notification_task.delay(user.id, old_email, new_email) + except Exception as e: + logger.error(f"Celery not available, using fallback. Error: {e}") + send_email_change_verification_task(user.id, new_email, verify_url) + send_email_change_notification_task(user.id, old_email, new_email) + + return Response({"detail": "Ověřovací e-mail byl odeslán na novou adresu."}) + + +@extend_schema( + tags=["account"], + summary="Confirm email address change via link", + description="Verifies uid + token from the confirmation email, swaps email to pending_email.", + parameters=[ + OpenApiParameter(name="uidb64", type=str, location=OpenApiParameter.PATH), + OpenApiParameter(name="token", type=str, location=OpenApiParameter.PATH), + ], + responses={ + 200: OpenApiResponse(description="Email changed successfully."), + 400: OpenApiResponse(description="Invalid or expired link."), + }, +) +class ConfirmEmailChangeView(APIView): + def get(self, request, uidb64, token): + ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "unknown")) + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (User.DoesNotExist, ValueError, TypeError): + return Response({"error": "Neplatný odkaz."}, status=status.HTTP_400_BAD_REQUEST) + + if not user.pending_email: + return Response({"error": "Žádná čekající změna e-mailu."}, status=status.HTTP_400_BAD_REQUEST) + + if not user.verify_email_token(token): + return Response({"error": "Token je neplatný nebo expirovaný."}, status=status.HTTP_400_BAD_REQUEST) + + old_email = user.email + user.email = user.pending_email + user.pending_email = None + user.email_verified = True + user.save(update_fields=["email", "pending_email", "email_verified"]) + logger.info("Email changed for user %s: %s → %s (IP: %s)", user.pk, old_email, user.email, ip) + + return Response({"detail": "E-mail byl úspěšně změněn."}) + + +@extend_schema( + tags=["account"], + summary="Change username", + description="Updates the username. Requires Turnstile. Limited to once per 30 days.", + responses={ + 200: OpenApiResponse(description="Username changed."), + 400: OpenApiResponse(description="Duplicate name, invalid characters, or failed CAPTCHA."), + 429: OpenApiResponse(description="Rate limited — days_remaining returned."), + }, +) +class ChangeUsernameView(APIView): + permission_classes = [IsAuthenticated] + COOLDOWN_DAYS = 30 + + def post(self, request): + from django.core.cache import cache + import time + + turnstile_token = request.data.get("turnstile_token", "") + remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")) + if not verify_turnstile(turnstile_token, remote_ip): + return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST) + + serializer = ChangeUsernameSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = request.user + cache_key = f"username_change_{user.id}" + changed_at = cache.get(cache_key) + if changed_at is not None: + remaining_days = int(self.COOLDOWN_DAYS - (time.time() - changed_at) / 86400) + if remaining_days > 0: + return Response( + {"detail": "Příliš brzy.", "days_remaining": remaining_days}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + user.username = serializer.validated_data["new_username"] + user.save(update_fields=["username"]) + cache.set(cache_key, time.time(), timeout=self.COOLDOWN_DAYS * 86400) + + return Response({"detail": "Uživatelské jméno bylo změněno.", "username": user.username}) + + +@extend_schema( + tags=["account"], + summary="Delete own account", + description="Soft-deletes the authenticated user's account after password confirmation. Clears auth cookies.", + responses={ + 204: OpenApiResponse(description="Account deleted."), + 400: OpenApiResponse(description="Wrong password."), + }, +) +class DeleteAccountView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + + turnstile_token = request.data.get("turnstile_token", "") + remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")) + if not verify_turnstile(turnstile_token, remote_ip): + return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST) + + password = request.data.get("current_password", "") + if not password or not user.check_password(password): + return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST) + + logger.info("Account deletion requested for user %s (id=%s)", user.username, user.pk) + django_logout(request) + user.delete() + + response = Response(status=status.HTTP_204_NO_CONTENT) + response.delete_cookie("access_token", path="/") + response.delete_cookie("refresh_token", path="/") + return response diff --git a/backend/notifications/tasks.py b/backend/notifications/tasks.py index 5f8c766..85062bc 100644 --- a/backend/notifications/tasks.py +++ b/backend/notifications/tasks.py @@ -110,10 +110,11 @@ def _build_user_template_ctx(user: CustomUser) -> dict: return { "id": user.pk, "email": getattr(user, "email", "") or "", + "username": getattr(user, "username", "") or "", "first_name": first_name, - "firstname": first_name, # alias for templates using `firstname` + "firstname": first_name, "last_name": last_name, - "lastname": last_name, # alias for templates using `lastname` + "lastname": last_name, "full_name": full_name, - "get_full_name": full_name, # compatibility for templates using method-style access + "get_full_name": full_name, } \ No newline at end of file diff --git a/backend/templates/email/action_confirm.html b/backend/templates/email/action_confirm.html new file mode 100644 index 0000000..84be36c --- /dev/null +++ b/backend/templates/email/action_confirm.html @@ -0,0 +1,65 @@ + + +
+

{{ title }}

+ +

{{ description|safe }}

+ + {% if action_url and cta_label %} + {{ cta_label }} + +

+ Pokud tlačítko nefunguje, zkopírujte tento odkaz do prohlížeče: + {{ action_url }} +

+ {% endif %} + + {% if note %} +

{{ note }}

+ {% endif %} +
diff --git a/frontend/.env.example b/frontend/.env.example index c11d6e4..d65faad 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,5 @@ # Base URL of the Django backend (must include /api/ if your axios baseURL expects it). -VITE_BACKEND_URL="http://localhost:8000/api/" +VITE_BACKEND_URL="http://localhost:8000/" VITE_BACKEND_WS_URL="ws://localhost:8000/" # Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6678dfc..aac8879 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,11 +16,13 @@ import PublicOnlyRoute from "./routes/PublicOnlyRoute"; import PortfolioPage from "./pages/portfolio/PortfolioPage"; import ContactPage from "./pages/contact/ContactPage"; import ScrollToTop from "./components/common/ScrollToTop"; +import TopBanner from "./components/common/TopBanner"; import LogoutPage from "./pages/social/account/Logout"; import LoginPage from "./pages/social/account/Login"; import RegisterPage from "./pages/social/account/Register"; import PasswordResetPage from "./pages/social/account/PasswordResetPage"; +import ConfirmEmailChangePage from "./pages/social/account/ConfirmEmailChangePage"; import { RetroSoundTest } from "./pages/test/sounds"; // Social pages @@ -41,6 +43,7 @@ export default function App() { return ( + {/* Public marketing routes */} }> @@ -61,6 +64,9 @@ export default function App() { } /> + {/* Email change confirmation — public, verified by token */} + } /> + {/* Authenticated social area */} }> }> diff --git a/frontend/src/api/generated/private/account/account.ts b/frontend/src/api/generated/private/account/account.ts index 8eb0eba..b4f3057 100644 --- a/frontend/src/api/generated/private/account/account.ts +++ b/frontend/src/api/generated/private/account/account.ts @@ -57,6 +57,420 @@ type NonReadonly = [T] extends [UnionToIntersection] } : DistributeReadOnlyOverUnions; +/** + * Validates current password + Turnstile, stores pending_email, sends confirmation link to the new address and a notification to the old one. + * @summary Request email address change + */ +export const apiAccountChangeEmailCreate = (signal?: AbortSignal) => { + return privateMutator({ + url: `/api/account/change-email/`, + method: "POST", + signal, + }); +}; + +export const getApiAccountChangeEmailCreateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["apiAccountChangeEmailCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return apiAccountChangeEmailCreate(); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiAccountChangeEmailCreateMutationResult = NonNullable< + Awaited> +>; + +export type ApiAccountChangeEmailCreateMutationError = void; + +/** + * @summary Request email address change + */ +export const useApiAccountChangeEmailCreate = < + TError = void, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation( + getApiAccountChangeEmailCreateMutationOptions(options), + queryClient, + ); +}; +/** + * Updates the username. Requires Turnstile. Limited to once per 30 days. + * @summary Change username + */ +export const apiAccountChangeUsernameCreate = (signal?: AbortSignal) => { + return privateMutator({ + url: `/api/account/change-username/`, + method: "POST", + signal, + }); +}; + +export const getApiAccountChangeUsernameCreateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["apiAccountChangeUsernameCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return apiAccountChangeUsernameCreate(); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiAccountChangeUsernameCreateMutationResult = NonNullable< + Awaited> +>; + +export type ApiAccountChangeUsernameCreateMutationError = void; + +/** + * @summary Change username + */ +export const useApiAccountChangeUsernameCreate = < + TError = void, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation( + getApiAccountChangeUsernameCreateMutationOptions(options), + queryClient, + ); +}; +/** + * Verifies uid + token from the confirmation email, swaps email to pending_email. + * @summary Confirm email address change via link + */ +export const apiAccountConfirmEmailChangeRetrieve = ( + uidb64: string, + token: string, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/account/confirm-email-change/${uidb64}/${token}/`, + method: "GET", + signal, + }); +}; + +export const getApiAccountConfirmEmailChangeRetrieveQueryKey = ( + uidb64: string, + token: string, +) => { + return [`/api/account/confirm-email-change/${uidb64}/${token}/`] as const; +}; + +export const getApiAccountConfirmEmailChangeRetrieveQueryOptions = < + TData = Awaited>, + TError = void, +>( + uidb64: string, + token: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getApiAccountConfirmEmailChangeRetrieveQueryKey(uidb64, token); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + apiAccountConfirmEmailChangeRetrieve(uidb64, token, signal); + + return { + queryKey, + queryFn, + enabled: !!(uidb64 && token), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiAccountConfirmEmailChangeRetrieveQueryResult = NonNullable< + Awaited> +>; +export type ApiAccountConfirmEmailChangeRetrieveQueryError = void; + +export function useApiAccountConfirmEmailChangeRetrieve< + TData = Awaited>, + TError = void, +>( + uidb64: string, + token: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiAccountConfirmEmailChangeRetrieve< + TData = Awaited>, + TError = void, +>( + uidb64: string, + token: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiAccountConfirmEmailChangeRetrieve< + TData = Awaited>, + TError = void, +>( + uidb64: string, + token: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Confirm email address change via link + */ + +export function useApiAccountConfirmEmailChangeRetrieve< + TData = Awaited>, + TError = void, +>( + uidb64: string, + token: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApiAccountConfirmEmailChangeRetrieveQueryOptions( + uidb64, + token, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Soft-deletes the authenticated user's account after password confirmation. Clears auth cookies. + * @summary Delete own account + */ +export const apiAccountDeleteCreate = (signal?: AbortSignal) => { + return privateMutator({ + url: `/api/account/delete/`, + method: "POST", + signal, + }); +}; + +export const getApiAccountDeleteCreateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["apiAccountDeleteCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return apiAccountDeleteCreate(); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiAccountDeleteCreateMutationResult = NonNullable< + Awaited> +>; + +export type ApiAccountDeleteCreateMutationError = void; + +/** + * @summary Delete own account + */ +export const useApiAccountDeleteCreate = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation( + getApiAccountDeleteCreateMutationOptions(options), + queryClient, + ); +}; /** * @summary Change password for the authenticated user */ @@ -143,6 +557,86 @@ export const useApiAccountPasswordChangeCreate = < queryClient, ); }; +/** + * Resends the verification email to the currently authenticated user. Limited to once per minute. + * @summary Resend email verification + */ +export const apiAccountResendVerificationCreate = (signal?: AbortSignal) => { + return privateMutator({ + url: `/api/account/resend-verification/`, + method: "POST", + signal, + }); +}; + +export const getApiAccountResendVerificationCreateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["apiAccountResendVerificationCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return apiAccountResendVerificationCreate(); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiAccountResendVerificationCreateMutationResult = NonNullable< + Awaited> +>; + +export type ApiAccountResendVerificationCreateMutationError = void; + +/** + * @summary Resend email verification + */ +export const useApiAccountResendVerificationCreate = < + TError = void, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation( + getApiAccountResendVerificationCreateMutationOptions(options), + queryClient, + ); +}; /** * Returns details of the currently authenticated user based on JWT token or session. * @summary Get current authenticated user diff --git a/frontend/src/components/common/TopBanner.tsx b/frontend/src/components/common/TopBanner.tsx new file mode 100644 index 0000000..0993171 --- /dev/null +++ b/frontend/src/components/common/TopBanner.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from "react"; +import { FiAlertCircle, FiMail } from "react-icons/fi"; +import { useAuth } from "@/hooks/useAuth"; +import { privateApi } from "@/api/privateClient"; + +export const BANNER_H = "40px"; + +export default function TopBanner() { + const { user, isLoading } = useAuth(); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const [cooldown, setCooldown] = useState(0); + + const show = !isLoading && !!user && user.email_verified === false; + + useEffect(() => { + document.documentElement.style.setProperty("--top-banner-h", show ? BANNER_H : "0px"); + }, [show]); + + useEffect(() => { + if (cooldown <= 0) return; + const t = setTimeout(() => setCooldown((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [cooldown]); + + async function handleResend() { + setSending(true); + try { + await privateApi.post("/api/account/resend-verification/"); + setSent(true); + } catch (err: any) { + const secs = err?.response?.data?.seconds_remaining; + if (secs) setCooldown(secs); + } finally { + setSending(false); + } + } + + if (!show) return null; + + return ( +
+ + Tvůj e-mail ještě není ověřen. + + {sent ? ( + E-mail odeslán! + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/home/ContactMe/ContactMeForm.tsx b/frontend/src/components/home/ContactMe/ContactMeForm.tsx index 51357a3..284bc76 100644 --- a/frontend/src/components/home/ContactMe/ContactMeForm.tsx +++ b/frontend/src/components/home/ContactMe/ContactMeForm.tsx @@ -132,7 +132,7 @@ export default function ContactMeForm() { {error && (

{error}

)} - {turnstileEnabled &&
} + {turnstileEnabled &&
}
diff --git a/frontend/src/components/home/ContactMe/contact-me.module.css b/frontend/src/components/home/ContactMe/contact-me.module.css index dd8eb6a..4d76adf 100644 --- a/frontend/src/components/home/ContactMe/contact-me.module.css +++ b/frontend/src/components/home/ContactMe/contact-me.module.css @@ -47,7 +47,7 @@ transition: all 1s ease-out; } .content-moveup{ - transform: translateY(-70%); + transform: translateY(-80%); } .content-moveup-index { z-index: 2 !important; } diff --git a/frontend/src/components/home/navbar/navbar.module.css b/frontend/src/components/home/navbar/navbar.module.css index 989c9e9..b71d3ac 100644 --- a/frontend/src/components/home/navbar/navbar.module.css +++ b/frontend/src/components/home/navbar/navbar.module.css @@ -15,7 +15,7 @@ justify-content: space-between; align-items: center; position: fixed; - top: 1rem; + top: calc(1rem + var(--top-banner-h, 0px)); left: 50%; transform: translateX(-50%); z-index: 100; @@ -292,7 +292,7 @@ .navbar { width: 100%; max-width: 100%; - top: 0; + top: var(--top-banner-h, 0px); border-radius: 0; padding: 0.7em 1.2em; border-left: none; diff --git a/frontend/src/layouts/social/SocialLayout.tsx b/frontend/src/layouts/social/SocialLayout.tsx index 6673ed6..dd90912 100644 --- a/frontend/src/layouts/social/SocialLayout.tsx +++ b/frontend/src/layouts/social/SocialLayout.tsx @@ -59,7 +59,7 @@ export default function SocialLayout() { * This ensures the middle row is always exactly the right height so * nothing is hidden behind the fixed bottom nav. */ -
+
{/* ── Mobile top bar ── */}
{items.map((it) => ( diff --git a/frontend/src/pages/social/AccountSettingsPage.tsx b/frontend/src/pages/social/AccountSettingsPage.tsx index 69e1715..6eceb10 100644 --- a/frontend/src/pages/social/AccountSettingsPage.tsx +++ b/frontend/src/pages/social/AccountSettingsPage.tsx @@ -3,8 +3,12 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; -import { FiArrowLeft, FiUser, FiLock, FiCamera, FiImage } from "react-icons/fi"; +import { + FiArrowLeft, FiUser, FiLock, FiCamera, FiImage, + FiMail, FiAtSign, FiShield, FiAlertTriangle, FiTrash2, +} from "react-icons/fi"; import { useAuth } from "@/hooks/useAuth"; +import { useTurnstile } from "@/hooks/useTurnstile"; import { privateApi } from "@/api/privateClient"; import { mediaUrl } from "@/utils/mediaUrl"; import Avatar from "@/components/ui/Avatar"; @@ -13,7 +17,7 @@ import Spinner from "@/components/ui/Spinner"; import FormErrorBanner from "@/components/ui/FormErrorBanner"; import { applyServerErrors } from "@/utils/formErrors"; -type Tab = "profile" | "security"; +type Tab = "profile" | "account" | "security"; interface ProfileForm { first_name: string; @@ -22,10 +26,104 @@ interface ProfileForm { phone_number: string; } -interface PasswordForm { - current_password: string; - new_password: string; - confirm_password: string; +interface UsernameForm { new_username: string } +interface EmailForm { current_password: string; new_email: string } +interface PasswordForm { current_password: string; new_password: string; confirm_password: string } + +// ── Reusable section header ───────────────────────────────────── +function Section({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +// ── Feedback row ──────────────────────────────────────────────── +function Success({ msg }: { msg: string }) { + return ( +
+ {msg} +
+ ); +} + +// ── Delete account confirmation form (own component so useTurnstile mounts fresh) ── +function DeleteAccountForm({ + inputClass, + onCancel, + onDeleted, +}: { + inputClass: string; + onCancel: () => void; + onDeleted: () => void; +}) { + const navigate = useNavigate(); + const { refreshUser } = useAuth() as any; + const [password, setPassword] = useState(""); + const [error, setError] = useState(); + const [submitting, setSubmitting] = useState(false); + const { containerRef, token, enabled } = useTurnstile(); + + async function handleDelete() { + if (!password || !token) return; + setError(undefined); + setSubmitting(true); + try { + await privateApi.post("/api/account/delete/", { + current_password: password, + turnstile_token: token, + }); + await refreshUser?.(); + navigate("/"); + onDeleted(); + } catch (err: any) { + setError( + err?.response?.data?.detail ?? + err?.response?.data?.current_password ?? + "Chyba. Zkuste to znovu." + ); + } finally { + setSubmitting(false); + } + } + + return ( +
+

+ Pro potvrzení zadejte své heslo. Účet bude okamžitě deaktivován a budete odhlášeni. +

+ {error &&

{error}

} + setPassword(e.target.value)} + /> + {enabled &&
} +
+ + +
+
+ ); } export default function AccountSettingsPage() { @@ -35,9 +133,13 @@ export default function AccountSettingsPage() { const queryClient = useQueryClient(); const [tab, setTab] = useState("profile"); - // ── Profile form ────────────────────────────────────────────── + const inputClass = + "w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text " + + "placeholder:text-brand-text/30 focus:outline-none focus:border-brand-accent disabled:opacity-50"; + + // ── Profile ─────────────────────────────────────────────────── const [profileSuccess, setProfileSuccess] = useState(false); - const [profileRootError, setProfileRootError] = useState(); + const [profileError, setProfileError] = useState(); const profileForm = useForm({ defaultValues: { first_name: user?.first_name ?? "", @@ -46,27 +148,26 @@ export default function AccountSettingsPage() { phone_number: user?.phone_number ?? "", }, }); - const { register: regProfile, handleSubmit: handleProfile, formState: { isSubmitting: profileSubmitting } } = profileForm; + const { register: rP, handleSubmit: hP, formState: { isSubmitting: pSubmit } } = profileForm; async function onProfileSubmit(values: ProfileForm) { - setProfileRootError(undefined); + setProfileError(undefined); setProfileSuccess(false); try { await privateApi.patch(`/api/account/users/${user.id}/`, values); setProfileSuccess(true); await queryClient.invalidateQueries({ queryKey: ["account"] }); - if (refreshUser) await refreshUser(); + await refreshUser?.(); } catch (err) { - setProfileRootError(applyServerErrors(profileForm, err)); + setProfileError(applyServerErrors(profileForm, err)); } } - // ── Avatar upload ───────────────────────────────────────────── - const avatarInputRef = useRef(null); + // ── Avatar ──────────────────────────────────────────────────── + const avatarRef = useRef(null); const [avatarPreview, setAvatarPreview] = useState(null); const [avatarUploading, setAvatarUploading] = useState(false); - - async function handleAvatarChange(e: React.ChangeEvent) { + async function handleAvatar(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setAvatarPreview(URL.createObjectURL(file)); @@ -76,19 +177,15 @@ export default function AccountSettingsPage() { fd.append("avatar", file); await privateApi.patch(`/api/account/users/${user.id}/`, fd); await queryClient.invalidateQueries({ queryKey: ["account"] }); - if (refreshUser) await refreshUser(); - } finally { - setAvatarUploading(false); - e.target.value = ""; - } + await refreshUser?.(); + } finally { setAvatarUploading(false); e.target.value = ""; } } - // ── Banner upload ───────────────────────────────────────────── - const bannerInputRef = useRef(null); + // ── Banner ──────────────────────────────────────────────────── + const bannerRef = useRef(null); const [bannerPreview, setBannerPreview] = useState(null); const [bannerUploading, setBannerUploading] = useState(false); - - async function handleBannerChange(e: React.ChangeEvent) { + async function handleBanner(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setBannerPreview(URL.createObjectURL(file)); @@ -98,26 +195,84 @@ export default function AccountSettingsPage() { fd.append("banner", file); await privateApi.patch(`/api/account/users/${user.id}/`, fd); await queryClient.invalidateQueries({ queryKey: ["account"] }); - if (refreshUser) await refreshUser(); - } finally { - setBannerUploading(false); - e.target.value = ""; + await refreshUser?.(); + } finally { setBannerUploading(false); e.target.value = ""; } + } + + // ── Username change ─────────────────────────────────────────── + const [usernameSuccess, setUsernameSuccess] = useState(false); + const [usernameError, setUsernameError] = useState(); + const [daysRemaining, setDaysRemaining] = useState(null); + const usernameForm = useForm({ defaultValues: { new_username: "" } }); + const { register: rU, handleSubmit: hU, formState: { isSubmitting: uSubmit }, reset: resetUsername } = usernameForm; + const { containerRef: tUsernameRef, token: tUsernameToken, enabled: turnstileEnabled, reset: resetTUsername } = useTurnstile(); + + async function onUsernameSubmit(values: UsernameForm) { + if (!tUsernameToken) return; + setUsernameError(undefined); + setUsernameSuccess(false); + setDaysRemaining(null); + try { + await privateApi.post("/api/account/change-username/", { + new_username: values.new_username, + turnstile_token: tUsernameToken, + }); + setUsernameSuccess(true); + resetUsername(); + await refreshUser?.(); + } catch (err: any) { + const days = err?.response?.data?.days_remaining; + if (days) setDaysRemaining(days); + setUsernameError(applyServerErrors(usernameForm, err)); + resetTUsername(); } } - // ── Password form ───────────────────────────────────────────── + // ── Email change ────────────────────────────────────────────── + const [emailSuccess, setEmailSuccess] = useState(false); + const [emailError, setEmailError] = useState(); + const emailForm = useForm({ defaultValues: { current_password: "", new_email: "" } }); + const { register: rE, handleSubmit: hE, formState: { isSubmitting: eSubmit }, reset: resetEmail } = emailForm; + const { containerRef: tEmailRef, token: tEmailToken, reset: resetTEmail } = useTurnstile(); + + async function onEmailSubmit(values: EmailForm) { + if (!tEmailToken) return; + setEmailError(undefined); + setEmailSuccess(false); + try { + await privateApi.post("/api/account/change-email/", { + current_password: values.current_password, + new_email: values.new_email, + turnstile_token: tEmailToken, + }); + setEmailSuccess(true); + resetEmail(); + } catch (err) { + setEmailError(applyServerErrors(emailForm, err)); + resetTEmail(); + } + } + + // ── Account deletion ───────────────────────────────────────── + const [deleteOpen, setDeleteOpen] = useState(false); + + // ── Password change ─────────────────────────────────────────── const [passwordSuccess, setPasswordSuccess] = useState(false); - const [passwordRootError, setPasswordRootError] = useState(); + const [passwordError, setPasswordError] = useState(); const passwordForm = useForm({ defaultValues: { current_password: "", new_password: "", confirm_password: "" }, }); - const { register: regPassword, handleSubmit: handlePassword, formState: { isSubmitting: passwordSubmitting }, reset: resetPassword, setError: setPasswordError } = passwordForm; + const { + register: rPw, handleSubmit: hPw, + formState: { isSubmitting: pwSubmit }, + reset: resetPassword, setError: setPwError, + } = passwordForm; async function onPasswordSubmit(values: PasswordForm) { - setPasswordRootError(undefined); + setPasswordError(undefined); setPasswordSuccess(false); if (values.new_password !== values.confirm_password) { - setPasswordError("confirm_password", { message: t("accountSettings.passwordMismatch") }); + setPwError("confirm_password", { message: t("accountSettings.passwordMismatch") }); return; } try { @@ -128,7 +283,7 @@ export default function AccountSettingsPage() { setPasswordSuccess(true); resetPassword(); } catch (err) { - setPasswordRootError(applyServerErrors(passwordForm, err)); + setPasswordError(applyServerErrors(passwordForm, err)); } } @@ -136,14 +291,20 @@ export default function AccountSettingsPage() { const avatarSrc = avatarPreview ?? mediaUrl((user as any)?.avatar); const bannerSrc = bannerPreview ?? mediaUrl((user as any)?.banner); + const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { id: "profile", label: "Profil", icon: }, + { id: "account", label: "Účet", icon: }, + { id: "security", label: "Zabezpečení", icon: }, + ]; + const tabClass = (active: boolean) => [ - "flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors", - active ? "bg-brand-lines/15 text-brand-text" : "text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text", + "flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors w-full text-left", + active + ? "bg-brand-lines/15 text-brand-text" + : "text-brand-text/55 hover:bg-brand-lines/10 hover:text-brand-text", ].join(" "); - const inputClass = "w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/30 focus:outline-none focus:border-brand-accent disabled:opacity-50"; - return (
@@ -154,177 +315,297 @@ export default function AccountSettingsPage() { > -

{t("accountSettings.title")}

+

Nastavení účtu

-
- {/* Sidebar tabs */} -