done last commit before merging - fixed media URLSs S3
This commit is contained in:
16
backend/account/migrations/0004_customuser_pending_email.py
Normal file
16
backend/account/migrations/0004_customuser_pending_email.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -58,6 +58,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
|
|
||||||
email_verified = models.BooleanField(default=False)
|
email_verified = models.BooleanField(default=False)
|
||||||
email = models.EmailField(unique=True, db_index=True)
|
email = models.EmailField(unique=True, db_index=True)
|
||||||
|
pending_email = models.EmailField(null=True, blank=True)
|
||||||
|
|
||||||
# + fields for email verification flow
|
# + fields for email verification flow
|
||||||
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
|
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)
|
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):
|
def save(self, *args, **kwargs):
|
||||||
for field in self._NULLABLE_CHAR_FIELDS:
|
for field in self._NULLABLE_CHAR_FIELDS:
|
||||||
|
|||||||
@@ -217,6 +217,31 @@ class PasswordResetConfirmSerializer(serializers.Serializer):
|
|||||||
return value
|
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):
|
class ChangePasswordSerializer(serializers.Serializer):
|
||||||
current_password = serializers.CharField(write_only=True)
|
current_password = serializers.CharField(write_only=True)
|
||||||
new_password = serializers.CharField(write_only=True)
|
new_password = serializers.CharField(write_only=True)
|
||||||
|
|||||||
@@ -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 <strong>{user.username}</strong>. 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 <strong>{user.username}</strong> byla zahájena změna e-mailové adresy na <strong>{new_email}</strong>.",
|
||||||
|
"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
|
@shared_task
|
||||||
def send_email_test_task(email):
|
def send_email_test_task(email):
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ urlpatterns = [
|
|||||||
# Registration & email endpoints
|
# Registration & email endpoints
|
||||||
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
||||||
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
path('verify-email/<uidb64>/<token>/', 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/<uidb64>/<token>/', views.ConfirmEmailChangeView.as_view(), name='confirm-email-change'),
|
||||||
|
path('change-username/', views.ChangeUsernameView.as_view(), name='change-username'),
|
||||||
|
|
||||||
# Password reset endpoints
|
# Password reset endpoints
|
||||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from .serializers import *
|
|||||||
from .permissions import *
|
from .permissions import *
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
from .tokens import *
|
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
|
from django.conf import settings
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -431,6 +434,208 @@ class PasswordResetConfirmView(APIView):
|
|||||||
user.save()
|
user.save()
|
||||||
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||||
return Response(serializer.errors, status=400)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,11 @@ def _build_user_template_ctx(user: CustomUser) -> dict:
|
|||||||
return {
|
return {
|
||||||
"id": user.pk,
|
"id": user.pk,
|
||||||
"email": getattr(user, "email", "") or "",
|
"email": getattr(user, "email", "") or "",
|
||||||
|
"username": getattr(user, "username", "") or "",
|
||||||
"first_name": first_name,
|
"first_name": first_name,
|
||||||
"firstname": first_name, # alias for templates using `firstname`
|
"firstname": first_name,
|
||||||
"last_name": last_name,
|
"last_name": last_name,
|
||||||
"lastname": last_name, # alias for templates using `lastname`
|
"lastname": last_name,
|
||||||
"full_name": full_name,
|
"full_name": full_name,
|
||||||
"get_full_name": full_name, # compatibility for templates using method-style access
|
"get_full_name": full_name,
|
||||||
}
|
}
|
||||||
65
backend/templates/email/action_confirm.html
Normal file
65
backend/templates/email/action_confirm.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<style>
|
||||||
|
.ac-wrap {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.ac-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
.ac-body {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #444;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
.ac-btn {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #2563eb;
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
.ac-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #777;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ac-url {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #2563eb;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="ac-wrap">
|
||||||
|
<h1 class="ac-title">{{ title }}</h1>
|
||||||
|
|
||||||
|
<p class="ac-body">{{ description|safe }}</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<a href="{{ action_url }}" class="ac-btn">{{ cta_label }}</a>
|
||||||
|
|
||||||
|
<p style="font-size:13px; color:#999; margin: 4px 0 20px 0;">
|
||||||
|
Pokud tlačítko nefunguje, zkopírujte tento odkaz do prohlížeče:
|
||||||
|
<span class="ac-url">{{ action_url }}</span>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if note %}
|
||||||
|
<p class="ac-note">{{ note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
|
# 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/"
|
VITE_BACKEND_WS_URL="ws://localhost:8000/"
|
||||||
|
|
||||||
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL
|
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import PublicOnlyRoute from "./routes/PublicOnlyRoute";
|
|||||||
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
||||||
import ContactPage from "./pages/contact/ContactPage";
|
import ContactPage from "./pages/contact/ContactPage";
|
||||||
import ScrollToTop from "./components/common/ScrollToTop";
|
import ScrollToTop from "./components/common/ScrollToTop";
|
||||||
|
import TopBanner from "./components/common/TopBanner";
|
||||||
|
|
||||||
import LogoutPage from "./pages/social/account/Logout";
|
import LogoutPage from "./pages/social/account/Logout";
|
||||||
import LoginPage from "./pages/social/account/Login";
|
import LoginPage from "./pages/social/account/Login";
|
||||||
import RegisterPage from "./pages/social/account/Register";
|
import RegisterPage from "./pages/social/account/Register";
|
||||||
import PasswordResetPage from "./pages/social/account/PasswordResetPage";
|
import PasswordResetPage from "./pages/social/account/PasswordResetPage";
|
||||||
|
import ConfirmEmailChangePage from "./pages/social/account/ConfirmEmailChangePage";
|
||||||
import { RetroSoundTest } from "./pages/test/sounds";
|
import { RetroSoundTest } from "./pages/test/sounds";
|
||||||
|
|
||||||
// Social pages
|
// Social pages
|
||||||
@@ -41,6 +43,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
|
<TopBanner />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public marketing routes */}
|
{/* Public marketing routes */}
|
||||||
<Route path="/" element={<HomeLayout />}>
|
<Route path="/" element={<HomeLayout />}>
|
||||||
@@ -61,6 +64,9 @@ export default function App() {
|
|||||||
<Route path="password-reset" element={<PasswordResetPage />} />
|
<Route path="password-reset" element={<PasswordResetPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Email change confirmation — public, verified by token */}
|
||||||
|
<Route path="/account/confirm-email-change/:uidb64/:token" element={<ConfirmEmailChangePage />} />
|
||||||
|
|
||||||
{/* Authenticated social area */}
|
{/* Authenticated social area */}
|
||||||
<Route path="/social" element={<PrivateRoute />}>
|
<Route path="/social" element={<PrivateRoute />}>
|
||||||
<Route element={<SocialLayout />}>
|
<Route element={<SocialLayout />}>
|
||||||
|
|||||||
@@ -57,6 +57,420 @@ type NonReadonly<T> = [T] extends [UnionToIntersection<T>]
|
|||||||
}
|
}
|
||||||
: DistributeReadOnlyOverUnions<T>;
|
: DistributeReadOnlyOverUnions<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>({
|
||||||
|
url: `/api/account/change-email/`,
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiAccountChangeEmailCreateMutationOptions = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
|
||||||
|
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<ReturnType<typeof apiAccountChangeEmailCreate>>,
|
||||||
|
void
|
||||||
|
> = () => {
|
||||||
|
return apiAccountChangeEmailCreate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiAccountChangeEmailCreateMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ApiAccountChangeEmailCreateMutationError = void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Request email address change
|
||||||
|
*/
|
||||||
|
export const useApiAccountChangeEmailCreate = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeEmailCreate>>,
|
||||||
|
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<void>({
|
||||||
|
url: `/api/account/change-username/`,
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiAccountChangeUsernameCreateMutationOptions = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
|
||||||
|
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<ReturnType<typeof apiAccountChangeUsernameCreate>>,
|
||||||
|
void
|
||||||
|
> = () => {
|
||||||
|
return apiAccountChangeUsernameCreate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiAccountChangeUsernameCreateMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ApiAccountChangeUsernameCreateMutationError = void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Change username
|
||||||
|
*/
|
||||||
|
export const useApiAccountChangeUsernameCreate = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof apiAccountChangeUsernameCreate>>,
|
||||||
|
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<void>({
|
||||||
|
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<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError = void,
|
||||||
|
>(
|
||||||
|
uidb64: string,
|
||||||
|
token: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { query: queryOptions } = options ?? {};
|
||||||
|
|
||||||
|
const queryKey =
|
||||||
|
queryOptions?.queryKey ??
|
||||||
|
getApiAccountConfirmEmailChangeRetrieveQueryKey(uidb64, token);
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
|
||||||
|
> = ({ signal }) =>
|
||||||
|
apiAccountConfirmEmailChangeRetrieve(uidb64, token, signal);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !!(uidb64 && token),
|
||||||
|
...queryOptions,
|
||||||
|
} as UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiAccountConfirmEmailChangeRetrieveQueryResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
|
||||||
|
>;
|
||||||
|
export type ApiAccountConfirmEmailChangeRetrieveQueryError = void;
|
||||||
|
|
||||||
|
export function useApiAccountConfirmEmailChangeRetrieve<
|
||||||
|
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError = void,
|
||||||
|
>(
|
||||||
|
uidb64: string,
|
||||||
|
token: string,
|
||||||
|
options: {
|
||||||
|
query: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
|
||||||
|
>,
|
||||||
|
"initialData"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): DefinedUseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
export function useApiAccountConfirmEmailChangeRetrieve<
|
||||||
|
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError = void,
|
||||||
|
>(
|
||||||
|
uidb64: string,
|
||||||
|
token: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>
|
||||||
|
>,
|
||||||
|
"initialData"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
export function useApiAccountConfirmEmailChangeRetrieve<
|
||||||
|
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError = void,
|
||||||
|
>(
|
||||||
|
uidb64: string,
|
||||||
|
token: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @summary Confirm email address change via link
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useApiAccountConfirmEmailChangeRetrieve<
|
||||||
|
TData = Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError = void,
|
||||||
|
>(
|
||||||
|
uidb64: string,
|
||||||
|
token: string,
|
||||||
|
options?: {
|
||||||
|
query?: Partial<
|
||||||
|
UseQueryOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountConfirmEmailChangeRetrieve>>,
|
||||||
|
TError,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseQueryResult<TData, TError> & {
|
||||||
|
queryKey: DataTag<QueryKey, TData, TError>;
|
||||||
|
} {
|
||||||
|
const queryOptions = getApiAccountConfirmEmailChangeRetrieveQueryOptions(
|
||||||
|
uidb64,
|
||||||
|
token,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||||
|
TData,
|
||||||
|
TError
|
||||||
|
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
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<void>({
|
||||||
|
url: `/api/account/delete/`,
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiAccountDeleteCreateMutationOptions = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
|
||||||
|
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<ReturnType<typeof apiAccountDeleteCreate>>,
|
||||||
|
void
|
||||||
|
> = () => {
|
||||||
|
return apiAccountDeleteCreate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiAccountDeleteCreateMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof apiAccountDeleteCreate>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ApiAccountDeleteCreateMutationError = void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete own account
|
||||||
|
*/
|
||||||
|
export const useApiAccountDeleteCreate = <TError = void, TContext = unknown>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof apiAccountDeleteCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(
|
||||||
|
getApiAccountDeleteCreateMutationOptions(options),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @summary Change password for the authenticated user
|
* @summary Change password for the authenticated user
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +557,86 @@ export const useApiAccountPasswordChangeCreate = <
|
|||||||
queryClient,
|
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<void>({
|
||||||
|
url: `/api/account/resend-verification/`,
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiAccountResendVerificationCreateMutationOptions = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
|
||||||
|
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<ReturnType<typeof apiAccountResendVerificationCreate>>,
|
||||||
|
void
|
||||||
|
> = () => {
|
||||||
|
return apiAccountResendVerificationCreate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiAccountResendVerificationCreateMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ApiAccountResendVerificationCreateMutationError = void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Resend email verification
|
||||||
|
*/
|
||||||
|
export const useApiAccountResendVerificationCreate = <
|
||||||
|
TError = void,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof apiAccountResendVerificationCreate>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(
|
||||||
|
getApiAccountResendVerificationCreateMutationOptions(options),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Returns details of the currently authenticated user based on JWT token or session.
|
* Returns details of the currently authenticated user based on JWT token or session.
|
||||||
* @summary Get current authenticated user
|
* @summary Get current authenticated user
|
||||||
|
|||||||
69
frontend/src/components/common/TopBanner.tsx
Normal file
69
frontend/src/components/common/TopBanner.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed top-0 left-0 right-0 z-[200] flex items-center justify-center gap-2.5 px-12 text-amber-200 text-[0.8rem]"
|
||||||
|
style={{
|
||||||
|
height: BANNER_H,
|
||||||
|
background: "color-mix(in hsl, #92400e, var(--c-background) 15%)",
|
||||||
|
borderBottom: "1px solid color-mix(in hsl, #f59e0b, transparent 55%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiAlertCircle size={14} className="shrink-0 text-amber-400" />
|
||||||
|
<span>Tvůj e-mail ještě není ověřen.</span>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<span className="text-emerald-300 font-semibold">E-mail odeslán!</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={sending || cooldown > 0}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-amber-400/55 bg-amber-400/25 px-2.5 py-0.5 text-[0.75rem] font-semibold text-amber-200 transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<FiMail size={11} />
|
||||||
|
{sending ? "Odesílám…" : cooldown > 0 ? `Počkej ${cooldown}s` : "Odeslat znovu"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ export default function ContactMeForm() {
|
|||||||
{error && (
|
{error && (
|
||||||
<p style={{ color: "#ff6b6b", fontSize: "0.8rem", margin: "0", textAlign: "center" }}>{error}</p>
|
<p style={{ color: "#ff6b6b", fontSize: "0.8rem", margin: "0", textAlign: "center" }}>{error}</p>
|
||||||
)}
|
)}
|
||||||
{turnstileEnabled && <div style={{ display: "flex", justifyContent: "center" }}><div ref={containerRef} /></div>}
|
{turnstileEnabled && <div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}><div ref={containerRef} /></div>}
|
||||||
<input type="submit" value={loading ? t("contact.sendingButton") : t("contact.sendButton")} disabled={loading || !turnstileToken} />
|
<input type="submit" value={loading ? t("contact.sendingButton") : t("contact.sendButton")} disabled={loading || !turnstileToken} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
transition: all 1s ease-out;
|
transition: all 1s ease-out;
|
||||||
}
|
}
|
||||||
.content-moveup{
|
.content-moveup{
|
||||||
transform: translateY(-70%);
|
transform: translateY(-80%);
|
||||||
}
|
}
|
||||||
.content-moveup-index { z-index: 2 !important; }
|
.content-moveup-index { z-index: 2 !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: calc(1rem + var(--top-banner-h, 0px));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
.navbar {
|
.navbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
top: 0;
|
top: var(--top-banner-h, 0px);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0.7em 1.2em;
|
padding: 0.7em 1.2em;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function SocialLayout() {
|
|||||||
* This ensures the middle row is always exactly the right height so
|
* This ensures the middle row is always exactly the right height so
|
||||||
* nothing is hidden behind the fixed bottom nav.
|
* nothing is hidden behind the fixed bottom nav.
|
||||||
*/
|
*/
|
||||||
<div className="flex flex-col" style={{ height: "100svh" }}>
|
<div className="flex flex-col" style={{ height: "100svh", paddingTop: "var(--top-banner-h, 0px)" }}>
|
||||||
|
|
||||||
{/* ── Mobile top bar ── */}
|
{/* ── Mobile top bar ── */}
|
||||||
<div
|
<div
|
||||||
@@ -172,7 +172,7 @@ export default function SocialLayout() {
|
|||||||
|
|
||||||
{/* ── Fixed bottom tab bar — mobile only ── */}
|
{/* ── Fixed bottom tab bar — mobile only ── */}
|
||||||
<nav
|
<nav
|
||||||
className="md:hidden"
|
className="flex md:hidden"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 50,
|
position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 50,
|
||||||
height: BOTTOM_NAV_H,
|
height: BOTTOM_NAV_H,
|
||||||
@@ -180,7 +180,6 @@ export default function SocialLayout() {
|
|||||||
backdropFilter: "blur(20px)",
|
backdropFilter: "blur(20px)",
|
||||||
WebkitBackdropFilter: "blur(20px)",
|
WebkitBackdropFilter: "blur(20px)",
|
||||||
borderTop: "1px solid color-mix(in hsl, var(--c-lines), transparent 65%)",
|
borderTop: "1px solid color-mix(in hsl, var(--c-lines), transparent 65%)",
|
||||||
display: "flex",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
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 { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useTurnstile } from "@/hooks/useTurnstile";
|
||||||
import { privateApi } from "@/api/privateClient";
|
import { privateApi } from "@/api/privateClient";
|
||||||
import { mediaUrl } from "@/utils/mediaUrl";
|
import { mediaUrl } from "@/utils/mediaUrl";
|
||||||
import Avatar from "@/components/ui/Avatar";
|
import Avatar from "@/components/ui/Avatar";
|
||||||
@@ -13,7 +17,7 @@ import Spinner from "@/components/ui/Spinner";
|
|||||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||||
import { applyServerErrors } from "@/utils/formErrors";
|
import { applyServerErrors } from "@/utils/formErrors";
|
||||||
|
|
||||||
type Tab = "profile" | "security";
|
type Tab = "profile" | "account" | "security";
|
||||||
|
|
||||||
interface ProfileForm {
|
interface ProfileForm {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -22,10 +26,104 @@ interface ProfileForm {
|
|||||||
phone_number: string;
|
phone_number: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PasswordForm {
|
interface UsernameForm { new_username: string }
|
||||||
current_password: string;
|
interface EmailForm { current_password: string; new_email: string }
|
||||||
new_password: string;
|
interface PasswordForm { current_password: string; new_password: string; confirm_password: string }
|
||||||
confirm_password: string;
|
|
||||||
|
// ── Reusable section header ─────────────────────────────────────
|
||||||
|
function Section({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-sm font-semibold text-brand-text">{title}</h2>
|
||||||
|
{subtitle && <p className="mt-0.5 text-xs text-brand-text/50">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feedback row ────────────────────────────────────────────────
|
||||||
|
function Success({ msg }: { msg: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<string>();
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-brand-text/60">
|
||||||
|
Pro potvrzení zadejte své heslo. Účet bude okamžitě deaktivován a budete odhlášeni.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass + " border-red-500/30 focus:border-red-400"}
|
||||||
|
placeholder="Vaše heslo"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{enabled && <div ref={containerRef} className="flex justify-center" />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 rounded-xl border border-brand-lines/20 bg-brand-bgLight/30 px-3 py-2 text-xs font-medium text-brand-text/70 hover:bg-brand-bgLight/50 transition-colors"
|
||||||
|
>
|
||||||
|
Zrušit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={submitting || !password || !token}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl bg-red-600/80 px-3 py-2 text-xs font-semibold text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? <Spinner size={12} /> : <FiTrash2 size={12} />}
|
||||||
|
{submitting ? "Mažu…" : "Potvrdit smazání"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountSettingsPage() {
|
export default function AccountSettingsPage() {
|
||||||
@@ -35,9 +133,13 @@ export default function AccountSettingsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<Tab>("profile");
|
const [tab, setTab] = useState<Tab>("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 [profileSuccess, setProfileSuccess] = useState(false);
|
||||||
const [profileRootError, setProfileRootError] = useState<string>();
|
const [profileError, setProfileError] = useState<string>();
|
||||||
const profileForm = useForm<ProfileForm>({
|
const profileForm = useForm<ProfileForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
first_name: user?.first_name ?? "",
|
first_name: user?.first_name ?? "",
|
||||||
@@ -46,27 +148,26 @@ export default function AccountSettingsPage() {
|
|||||||
phone_number: user?.phone_number ?? "",
|
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) {
|
async function onProfileSubmit(values: ProfileForm) {
|
||||||
setProfileRootError(undefined);
|
setProfileError(undefined);
|
||||||
setProfileSuccess(false);
|
setProfileSuccess(false);
|
||||||
try {
|
try {
|
||||||
await privateApi.patch(`/api/account/users/${user.id}/`, values);
|
await privateApi.patch(`/api/account/users/${user.id}/`, values);
|
||||||
setProfileSuccess(true);
|
setProfileSuccess(true);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
||||||
if (refreshUser) await refreshUser();
|
await refreshUser?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setProfileRootError(applyServerErrors(profileForm, err));
|
setProfileError(applyServerErrors(profileForm, err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Avatar upload ─────────────────────────────────────────────
|
// ── Avatar ────────────────────────────────────────────────────
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
const avatarRef = useRef<HTMLInputElement>(null);
|
||||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
|
async function handleAvatar(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setAvatarPreview(URL.createObjectURL(file));
|
setAvatarPreview(URL.createObjectURL(file));
|
||||||
@@ -76,19 +177,15 @@ export default function AccountSettingsPage() {
|
|||||||
fd.append("avatar", file);
|
fd.append("avatar", file);
|
||||||
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
|
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
||||||
if (refreshUser) await refreshUser();
|
await refreshUser?.();
|
||||||
} finally {
|
} finally { setAvatarUploading(false); e.target.value = ""; }
|
||||||
setAvatarUploading(false);
|
|
||||||
e.target.value = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Banner upload ─────────────────────────────────────────────
|
// ── Banner ────────────────────────────────────────────────────
|
||||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
const bannerRef = useRef<HTMLInputElement>(null);
|
||||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||||
const [bannerUploading, setBannerUploading] = useState(false);
|
const [bannerUploading, setBannerUploading] = useState(false);
|
||||||
|
async function handleBanner(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
async function handleBannerChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setBannerPreview(URL.createObjectURL(file));
|
setBannerPreview(URL.createObjectURL(file));
|
||||||
@@ -98,26 +195,84 @@ export default function AccountSettingsPage() {
|
|||||||
fd.append("banner", file);
|
fd.append("banner", file);
|
||||||
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
|
await privateApi.patch(`/api/account/users/${user.id}/`, fd);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
await queryClient.invalidateQueries({ queryKey: ["account"] });
|
||||||
if (refreshUser) await refreshUser();
|
await refreshUser?.();
|
||||||
} finally {
|
} finally { setBannerUploading(false); e.target.value = ""; }
|
||||||
setBannerUploading(false);
|
}
|
||||||
e.target.value = "";
|
|
||||||
|
// ── Username change ───────────────────────────────────────────
|
||||||
|
const [usernameSuccess, setUsernameSuccess] = useState(false);
|
||||||
|
const [usernameError, setUsernameError] = useState<string>();
|
||||||
|
const [daysRemaining, setDaysRemaining] = useState<number | null>(null);
|
||||||
|
const usernameForm = useForm<UsernameForm>({ 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<string>();
|
||||||
|
const emailForm = useForm<EmailForm>({ 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 [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||||
const [passwordRootError, setPasswordRootError] = useState<string>();
|
const [passwordError, setPasswordError] = useState<string>();
|
||||||
const passwordForm = useForm<PasswordForm>({
|
const passwordForm = useForm<PasswordForm>({
|
||||||
defaultValues: { current_password: "", new_password: "", confirm_password: "" },
|
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) {
|
async function onPasswordSubmit(values: PasswordForm) {
|
||||||
setPasswordRootError(undefined);
|
setPasswordError(undefined);
|
||||||
setPasswordSuccess(false);
|
setPasswordSuccess(false);
|
||||||
if (values.new_password !== values.confirm_password) {
|
if (values.new_password !== values.confirm_password) {
|
||||||
setPasswordError("confirm_password", { message: t("accountSettings.passwordMismatch") });
|
setPwError("confirm_password", { message: t("accountSettings.passwordMismatch") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +283,7 @@ export default function AccountSettingsPage() {
|
|||||||
setPasswordSuccess(true);
|
setPasswordSuccess(true);
|
||||||
resetPassword();
|
resetPassword();
|
||||||
} catch (err) {
|
} 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 avatarSrc = avatarPreview ?? mediaUrl((user as any)?.avatar);
|
||||||
const bannerSrc = bannerPreview ?? mediaUrl((user as any)?.banner);
|
const bannerSrc = bannerPreview ?? mediaUrl((user as any)?.banner);
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: "profile", label: "Profil", icon: <FiUser size={15} /> },
|
||||||
|
{ id: "account", label: "Účet", icon: <FiAtSign size={15} /> },
|
||||||
|
{ id: "security", label: "Zabezpečení", icon: <FiShield size={15} /> },
|
||||||
|
];
|
||||||
|
|
||||||
const tabClass = (active: boolean) =>
|
const tabClass = (active: boolean) =>
|
||||||
[
|
[
|
||||||
"flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
"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/60 hover:bg-brand-lines/10 hover:text-brand-text",
|
active
|
||||||
|
? "bg-brand-lines/15 text-brand-text"
|
||||||
|
: "text-brand-text/55 hover:bg-brand-lines/10 hover:text-brand-text",
|
||||||
].join(" ");
|
].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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
|
<header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
|
||||||
@@ -154,177 +315,297 @@ export default function AccountSettingsPage() {
|
|||||||
>
|
>
|
||||||
<FiArrowLeft size={20} />
|
<FiArrowLeft size={20} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-lg font-bold text-brand-text">{t("accountSettings.title")}</h1>
|
<h1 className="text-lg font-bold text-brand-text">Nastavení účtu</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex gap-0">
|
<div className="flex">
|
||||||
{/* Sidebar tabs */}
|
{/* Sidebar */}
|
||||||
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-1">
|
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-2">
|
||||||
<button type="button" className={tabClass(tab === "profile")} onClick={() => setTab("profile")}>
|
{tabs.map(({ id, label, icon }) => (
|
||||||
<FiUser size={16} /> {t("accountSettings.tabProfile")}
|
<button key={id} type="button" className={tabClass(tab === id)} onClick={() => setTab(id)}>
|
||||||
</button>
|
{icon} {label}
|
||||||
<button type="button" className={tabClass(tab === "security")} onClick={() => setTab("security")}>
|
</button>
|
||||||
<FiLock size={16} /> {t("accountSettings.tabSecurity")}
|
))}
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 p-6 max-w-lg">
|
<div className="flex-1 p-6 max-w-lg flex flex-col gap-8">
|
||||||
|
|
||||||
{/* ── Profile tab ── */}
|
{/* ── Profil ── */}
|
||||||
{tab === "profile" && (
|
{tab === "profile" && (
|
||||||
<div className="flex flex-col gap-6">
|
<>
|
||||||
{/* Appearance: banner + avatar */}
|
<Section title="Fotografie" subtitle="Profilová fotka a banner viditelné ostatním uživatelům." />
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-brand-text mb-3">{t("accountSettings.appearanceLabel")}</div>
|
|
||||||
|
|
||||||
{/* Banner */}
|
{/* Banner + avatar */}
|
||||||
<div className="relative mb-10">
|
<div className="relative mb-6">
|
||||||
<div
|
<div
|
||||||
className="group relative h-28 w-full cursor-pointer overflow-hidden rounded-2xl bg-gradient-to-br from-brand-bgLight to-brand-lines/20"
|
className="group relative h-28 w-full cursor-pointer overflow-hidden rounded-2xl bg-gradient-to-br from-brand-bgLight to-brand-lines/20"
|
||||||
onClick={() => bannerInputRef.current?.click()}
|
onClick={() => bannerRef.current?.click()}
|
||||||
>
|
>
|
||||||
{bannerSrc && (
|
{bannerSrc && <img src={bannerSrc} alt="" className="h-full w-full object-cover" />}
|
||||||
<img src={bannerSrc} alt="" className="h-full w-full object-cover" />
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/40">
|
||||||
|
{bannerUploading ? <Spinner size={22} /> : (
|
||||||
|
<div className="flex flex-col items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
|
<FiImage size={20} className="text-white" />
|
||||||
|
<span className="text-xs text-white/90">Změnit banner</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/40">
|
|
||||||
{bannerUploading ? (
|
|
||||||
<Spinner size={22} />
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100">
|
|
||||||
<FiImage size={20} className="text-white" />
|
|
||||||
<span className="text-xs text-white/90">{t("accountSettings.changeBanner")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input ref={bannerInputRef} type="file" accept="image/*" className="hidden" onChange={handleBannerChange} />
|
|
||||||
|
|
||||||
{/* Avatar overlapping banner bottom-left */}
|
|
||||||
<div className="absolute -bottom-8 left-4">
|
|
||||||
<div className="relative rounded-full ring-4 ring-brand-bg">
|
|
||||||
<Avatar name={displayName} src={avatarSrc} size={64} />
|
|
||||||
{avatarUploading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
|
||||||
<Spinner size={16} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => avatarInputRef.current?.click()}
|
|
||||||
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-brand-accent text-white shadow hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
<FiCamera size={11} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input ref={bannerRef} type="file" accept="image/*" className="hidden" onChange={handleBanner} />
|
||||||
|
|
||||||
<p className="text-xs text-brand-text/40">{t("accountSettings.fileHint")}</p>
|
<div className="absolute -bottom-8 left-4">
|
||||||
|
<div className="relative rounded-full ring-4 ring-brand-bg">
|
||||||
|
<Avatar name={displayName} src={avatarSrc} size={64} />
|
||||||
|
{avatarUploading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||||
|
<Spinner size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => avatarRef.current?.click()}
|
||||||
|
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-brand-accent text-white shadow hover:opacity-90"
|
||||||
|
>
|
||||||
|
<FiCamera size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input ref={avatarRef} type="file" accept="image/*" className="hidden" onChange={handleAvatar} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile form */}
|
<p className="text-xs text-brand-text/35 -mt-4">Maximální velikost souboru: 5 MB. Formáty: JPG, PNG, WebP.</p>
|
||||||
<form onSubmit={handleProfile(onProfileSubmit)} className="flex flex-col gap-4">
|
|
||||||
<FormErrorBanner message={profileRootError} />
|
<hr className="border-brand-lines/10" />
|
||||||
{profileSuccess && (
|
|
||||||
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
<Section title="Základní informace" subtitle="Zobrazené jméno a kontaktní údaje." />
|
||||||
{t("accountSettings.profileSaved")}
|
|
||||||
</div>
|
<form onSubmit={hP(onProfileSubmit)} className="flex flex-col gap-4">
|
||||||
)}
|
<FormErrorBanner message={profileError} />
|
||||||
|
{profileSuccess && <Success msg="Profil byl uložen." />}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.firstNameLabel")}</label>
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Jméno</label>
|
||||||
<input className={inputClass} placeholder={t("accountSettings.firstNamePlaceholder")} {...regProfile("first_name")} />
|
<input className={inputClass} placeholder="Jan" {...rP("first_name")} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.lastNameLabel")}</label>
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Příjmení</label>
|
||||||
<input className={inputClass} placeholder={t("accountSettings.lastNamePlaceholder")} {...regProfile("last_name")} />
|
<input className={inputClass} placeholder="Novák" {...rP("last_name")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.usernameLabel")}</label>
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Město</label>
|
||||||
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.username ?? ""} readOnly disabled />
|
<input className={inputClass} placeholder="Praha" {...rP("city")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.emailLabel")}</label>
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Telefon</label>
|
||||||
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.email ?? ""} readOnly disabled />
|
<input className={inputClass} placeholder="+420 ..." {...rP("phone_number")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<Button type="submit" disabled={pSubmit} leftIcon={pSubmit ? <Spinner size={14} /> : undefined}>
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.cityLabel")}</label>
|
{pSubmit ? "Ukládám…" : "Uložit profil"}
|
||||||
<input className={inputClass} placeholder={t("accountSettings.cityPlaceholder")} {...regProfile("city")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.phoneLabel")}</label>
|
|
||||||
<input className={inputClass} placeholder="+420 ..." {...regProfile("phone_number")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={profileSubmitting} leftIcon={profileSubmitting ? <Spinner size={14} /> : undefined}>
|
|
||||||
{profileSubmitting ? t("accountSettings.saving") : t("accountSettings.saveProfile")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Security tab ── */}
|
{/* ── Účet ── */}
|
||||||
|
{tab === "account" && (
|
||||||
|
<>
|
||||||
|
{/* Username */}
|
||||||
|
<div>
|
||||||
|
<Section
|
||||||
|
title="Uživatelské jméno"
|
||||||
|
subtitle={`Aktuálně: @${user?.username} · Lze měnit jednou za 30 dní.`}
|
||||||
|
/>
|
||||||
|
<form onSubmit={hU(onUsernameSubmit)} className="flex flex-col gap-3">
|
||||||
|
<FormErrorBanner message={usernameError} />
|
||||||
|
{daysRemaining !== null && (
|
||||||
|
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-sm text-amber-400">
|
||||||
|
Příliš brzy. Zkuste to za {daysRemaining} {daysRemaining === 1 ? "den" : "dní"}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{usernameSuccess && <Success msg="Uživatelské jméno bylo změněno." />}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Nové uživatelské jméno</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={user?.username}
|
||||||
|
{...rU("new_username", { required: true, minLength: 3 })}
|
||||||
|
/>
|
||||||
|
{usernameForm.formState.errors.new_username && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Minimálně 3 znaky.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{turnstileEnabled && <div ref={tUsernameRef} className="flex justify-center" />}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={uSubmit || !tUsernameToken}
|
||||||
|
leftIcon={uSubmit ? <Spinner size={14} /> : undefined}
|
||||||
|
>
|
||||||
|
{uSubmit ? "Ukládám…" : "Změnit jméno"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-brand-lines/10" />
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<Section
|
||||||
|
title="E-mailová adresa"
|
||||||
|
subtitle={`Aktuálně: ${user?.email} · Po odeslání potvrďte novou adresu kliknutím na odkaz v e-mailu.`}
|
||||||
|
/>
|
||||||
|
<form onSubmit={hE(onEmailSubmit)} className="flex flex-col gap-4">
|
||||||
|
<FormErrorBanner message={emailError} />
|
||||||
|
{emailSuccess && <Success msg="Ověřovací e-mail byl odeslán na novou adresu." />}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Stávající heslo</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...rE("current_password", { required: true })}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
{emailForm.formState.errors.current_password && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Nová e-mailová adresa</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="nova@adresa.cz"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
{...rE("new_email", { required: true })}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
{emailForm.formState.errors.new_email && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{turnstileEnabled && <div ref={tEmailRef} className="flex justify-center" />}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={eSubmit || !tEmailToken}
|
||||||
|
leftIcon={eSubmit ? <Spinner size={14} /> : <FiMail size={14} />}
|
||||||
|
>
|
||||||
|
{eSubmit ? "Odesílám…" : "Odeslat ověřovací e-mail"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Zabezpečení ── */}
|
||||||
{tab === "security" && (
|
{tab === "security" && (
|
||||||
<form onSubmit={handlePassword(onPasswordSubmit)} className="flex flex-col gap-4">
|
<div className="flex flex-col gap-8">
|
||||||
<div className="text-sm font-semibold text-brand-text">{t("accountSettings.changePassword")}</div>
|
|
||||||
<FormErrorBanner message={passwordRootError} />
|
{/* Password */}
|
||||||
{passwordSuccess && (
|
<div>
|
||||||
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
<Section title="Změna hesla" subtitle="Heslo musí mít alespoň 8 znaků, velké i malé písmeno a číslici." />
|
||||||
{t("accountSettings.passwordChanged")}
|
<form onSubmit={hPw(onPasswordSubmit)} className="flex flex-col gap-4">
|
||||||
|
<FormErrorBanner message={passwordError} />
|
||||||
|
{passwordSuccess && <Success msg="Heslo bylo úspěšně změněno." />}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Stávající heslo</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...rPw("current_password", { required: true })}
|
||||||
|
/>
|
||||||
|
{passwordForm.formState.errors.current_password && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Nové heslo</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...rPw("new_password", { required: true })}
|
||||||
|
/>
|
||||||
|
{passwordForm.formState.errors.new_password && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Povinné pole.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-brand-text/65">Potvrdit nové heslo</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...rPw("confirm_password", { required: true })}
|
||||||
|
/>
|
||||||
|
{passwordForm.formState.errors.confirm_password && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">
|
||||||
|
{passwordForm.formState.errors.confirm_password.message ?? "Povinné pole."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={pwSubmit}
|
||||||
|
leftIcon={pwSubmit ? <Spinner size={14} /> : <FiLock size={14} />}
|
||||||
|
>
|
||||||
|
{pwSubmit ? "Ukládám…" : "Změnit heslo"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 p-4 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiAlertTriangle size={15} className="text-red-400 shrink-0" />
|
||||||
|
<span className="text-sm font-semibold text-red-400">Nebezpečná zóna</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
{!deleteOpen ? (
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.currentPasswordLabel")}</label>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<input
|
<p className="text-xs text-brand-text/50">
|
||||||
type="password"
|
Trvale deaktivuje váš účet. Tato akce je nevratná.
|
||||||
className={inputClass}
|
</p>
|
||||||
placeholder="••••••••"
|
<button
|
||||||
{...regPassword("current_password", { required: t("accountSettings.required") })}
|
type="button"
|
||||||
/>
|
onClick={() => setDeleteOpen(true)}
|
||||||
{passwordForm.formState.errors.current_password && (
|
className="shrink-0 flex items-center gap-1.5 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/20 transition-colors"
|
||||||
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.current_password.message}</p>
|
>
|
||||||
|
<FiTrash2 size={13} /> Smazat účet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DeleteAccountForm
|
||||||
|
inputClass={inputClass}
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onDeleted={() => setDeleteOpen(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
</div>
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.newPasswordLabel")}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="••••••••"
|
|
||||||
{...regPassword("new_password", { required: t("accountSettings.required") })}
|
|
||||||
/>
|
|
||||||
{passwordForm.formState.errors.new_password && (
|
|
||||||
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.new_password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.confirmPasswordLabel")}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="••••••••"
|
|
||||||
{...regPassword("confirm_password", { required: t("accountSettings.required") })}
|
|
||||||
/>
|
|
||||||
{passwordForm.formState.errors.confirm_password && (
|
|
||||||
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.confirm_password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={passwordSubmitting} leftIcon={passwordSubmitting ? <Spinner size={14} /> : undefined}>
|
|
||||||
{passwordSubmitting ? t("accountSettings.saving") : t("accountSettings.changePasswordBtn")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
62
frontend/src/pages/social/account/ConfirmEmailChangePage.tsx
Normal file
62
frontend/src/pages/social/account/ConfirmEmailChangePage.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import { apiAccountConfirmEmailChangeRetrieve } from "@/api/generated/private/account/account";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import Spinner from "@/components/ui/Spinner";
|
||||||
|
|
||||||
|
type Status = "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export default function ConfirmEmailChangePage() {
|
||||||
|
const { uidb64, token } = useParams<{ uidb64: string; token: string }>();
|
||||||
|
const { refreshUser } = useAuth();
|
||||||
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uidb64 || !token) {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage("Neplatný odkaz.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiAccountConfirmEmailChangeRetrieve(uidb64, token)
|
||||||
|
.then(() => {
|
||||||
|
setStatus("success");
|
||||||
|
setMessage("E-mail byl úspěšně změněn.");
|
||||||
|
refreshUser?.();
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage(err?.response?.data?.error ?? "Odkaz je neplatný nebo expirovaný.");
|
||||||
|
});
|
||||||
|
}, [uidb64, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="glass rounded-2xl p-10 text-center max-w-sm w-full flex flex-col items-center gap-4">
|
||||||
|
{status === "loading" && <Spinner size={32} />}
|
||||||
|
|
||||||
|
{status === "success" && (
|
||||||
|
<>
|
||||||
|
<span className="text-5xl">✅</span>
|
||||||
|
<h1 className="text-xl font-bold text-brand-text">E-mail změněn</h1>
|
||||||
|
<p className="text-sm text-brand-text/60">{message}</p>
|
||||||
|
<Link to="/social/account/settings" className="text-sm text-brand-accent hover:underline">
|
||||||
|
Zpět na nastavení
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<>
|
||||||
|
<span className="text-5xl">❌</span>
|
||||||
|
<h1 className="text-xl font-bold text-brand-text">Chyba</h1>
|
||||||
|
<p className="text-sm text-brand-text/60">{message}</p>
|
||||||
|
<Link to="/social/account/settings" className="text-sm text-brand-accent hover:underline">
|
||||||
|
Zpět na nastavení
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
|
const _backendHost = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(import.meta.env.VITE_BACKEND_URL || "").host;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalises a media URL so it always resolves through the current origin
|
* Normalises a media URL:
|
||||||
* (Vite dev proxy → backend, or nginx in production).
|
* - External hosts (S3, CDN): returned as-is.
|
||||||
*
|
* - Backend / same-origin URLs: strip origin so the request goes through
|
||||||
* - Full URLs (http/https): strip the origin, keep only the path.
|
* the Vite dev proxy or nginx in production.
|
||||||
* - Relative paths without a leading slash: add one.
|
|
||||||
* - blob: / data: URLs: returned unchanged.
|
* - blob: / data: URLs: returned unchanged.
|
||||||
* - null / undefined / empty: returns null.
|
* - null / undefined / empty: returns null.
|
||||||
*/
|
*/
|
||||||
@@ -11,11 +18,12 @@ export function mediaUrl(src: string | null | undefined): string | null {
|
|||||||
if (!src) return null;
|
if (!src) return null;
|
||||||
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
|
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
|
||||||
try {
|
try {
|
||||||
// Full URL — strip origin so the request goes through the proxy/nginx
|
|
||||||
const url = new URL(src);
|
const url = new URL(src);
|
||||||
|
const isLocal =
|
||||||
|
url.host === window.location.host || url.host === _backendHost;
|
||||||
|
if (!isLocal) return src; // S3 / CDN — keep full URL
|
||||||
return url.pathname + (url.search || "");
|
return url.pathname + (url.search || "");
|
||||||
} catch {
|
} catch {
|
||||||
// Already a relative path
|
|
||||||
return src.startsWith("/") ? src : `/${src}`;
|
return src.startsWith("/") ? src : `/${src}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user