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 = 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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
def send_email_test_task(email):
|
||||
send_email_with_context(
|
||||
|
||||
@@ -16,6 +16,15 @@ urlpatterns = [
|
||||
# Registration & email endpoints
|
||||
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
||||
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
|
||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
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).
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<TopBanner />
|
||||
<Routes>
|
||||
{/* Public marketing routes */}
|
||||
<Route path="/" element={<HomeLayout />}>
|
||||
@@ -61,6 +64,9 @@ export default function App() {
|
||||
<Route path="password-reset" element={<PasswordResetPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Email change confirmation — public, verified by token */}
|
||||
<Route path="/account/confirm-email-change/:uidb64/:token" element={<ConfirmEmailChangePage />} />
|
||||
|
||||
{/* Authenticated social area */}
|
||||
<Route path="/social" element={<PrivateRoute />}>
|
||||
<Route element={<SocialLayout />}>
|
||||
|
||||
@@ -57,6 +57,420 @@ type NonReadonly<T> = [T] extends [UnionToIntersection<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
|
||||
*/
|
||||
@@ -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<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.
|
||||
* @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 && (
|
||||
<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} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
transition: all 1s ease-out;
|
||||
}
|
||||
.content-moveup{
|
||||
transform: translateY(-70%);
|
||||
transform: translateY(-80%);
|
||||
}
|
||||
.content-moveup-index { z-index: 2 !important; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
<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 ── */}
|
||||
<div
|
||||
@@ -172,7 +172,7 @@ export default function SocialLayout() {
|
||||
|
||||
{/* ── Fixed bottom tab bar — mobile only ── */}
|
||||
<nav
|
||||
className="md:hidden"
|
||||
className="flex md:hidden"
|
||||
style={{
|
||||
position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 50,
|
||||
height: BOTTOM_NAV_H,
|
||||
@@ -180,7 +180,6 @@ export default function SocialLayout() {
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
borderTop: "1px solid color-mix(in hsl, var(--c-lines), transparent 65%)",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{items.map((it) => (
|
||||
|
||||
@@ -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 (
|
||||
<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() {
|
||||
@@ -35,9 +133,13 @@ export default function AccountSettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
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 [profileRootError, setProfileRootError] = useState<string>();
|
||||
const [profileError, setProfileError] = useState<string>();
|
||||
const profileForm = useForm<ProfileForm>({
|
||||
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<HTMLInputElement>(null);
|
||||
// ── Avatar ────────────────────────────────────────────────────
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
|
||||
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
async function handleAvatar(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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<HTMLInputElement>(null);
|
||||
// ── Banner ────────────────────────────────────────────────────
|
||||
const bannerRef = useRef<HTMLInputElement>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [bannerUploading, setBannerUploading] = useState(false);
|
||||
|
||||
async function handleBannerChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
async function handleBanner(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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<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 [passwordRootError, setPasswordRootError] = useState<string>();
|
||||
const [passwordError, setPasswordError] = useState<string>();
|
||||
const passwordForm = useForm<PasswordForm>({
|
||||
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: <FiUser size={15} /> },
|
||||
{ id: "account", label: "Účet", icon: <FiAtSign size={15} /> },
|
||||
{ id: "security", label: "Zabezpečení", icon: <FiShield size={15} /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@@ -154,177 +315,297 @@ export default function AccountSettingsPage() {
|
||||
>
|
||||
<FiArrowLeft size={20} />
|
||||
</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>
|
||||
|
||||
<div className="flex gap-0">
|
||||
{/* Sidebar tabs */}
|
||||
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-1">
|
||||
<button type="button" className={tabClass(tab === "profile")} onClick={() => setTab("profile")}>
|
||||
<FiUser size={16} /> {t("accountSettings.tabProfile")}
|
||||
</button>
|
||||
<button type="button" className={tabClass(tab === "security")} onClick={() => setTab("security")}>
|
||||
<FiLock size={16} /> {t("accountSettings.tabSecurity")}
|
||||
</button>
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-[180px] shrink-0 border-r border-brand-lines/10 p-3 flex flex-col gap-2">
|
||||
{tabs.map(({ id, label, icon }) => (
|
||||
<button key={id} type="button" className={tabClass(tab === id)} onClick={() => setTab(id)}>
|
||||
{icon} {label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 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" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Appearance: banner + avatar */}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-brand-text mb-3">{t("accountSettings.appearanceLabel")}</div>
|
||||
<>
|
||||
<Section title="Fotografie" subtitle="Profilová fotka a banner viditelné ostatním uživatelům." />
|
||||
|
||||
{/* Banner */}
|
||||
<div className="relative mb-10">
|
||||
<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"
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
>
|
||||
{bannerSrc && (
|
||||
<img src={bannerSrc} alt="" className="h-full w-full object-cover" />
|
||||
{/* Banner + avatar */}
|
||||
<div className="relative mb-6">
|
||||
<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"
|
||||
onClick={() => bannerRef.current?.click()}
|
||||
>
|
||||
{bannerSrc && <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>
|
||||
<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>
|
||||
|
||||
{/* Profile form */}
|
||||
<form onSubmit={handleProfile(onProfileSubmit)} className="flex flex-col gap-4">
|
||||
<FormErrorBanner message={profileRootError} />
|
||||
{profileSuccess && (
|
||||
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
||||
{t("accountSettings.profileSaved")}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-brand-text/35 -mt-4">Maximální velikost souboru: 5 MB. Formáty: JPG, PNG, WebP.</p>
|
||||
|
||||
<hr className="border-brand-lines/10" />
|
||||
|
||||
<Section title="Základní informace" subtitle="Zobrazené jméno a kontaktní údaje." />
|
||||
|
||||
<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>
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.firstNameLabel")}</label>
|
||||
<input className={inputClass} placeholder={t("accountSettings.firstNamePlaceholder")} {...regProfile("first_name")} />
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/65">Jméno</label>
|
||||
<input className={inputClass} placeholder="Jan" {...rP("first_name")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.lastNameLabel")}</label>
|
||||
<input className={inputClass} placeholder={t("accountSettings.lastNamePlaceholder")} {...regProfile("last_name")} />
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/65">Příjmení</label>
|
||||
<input className={inputClass} placeholder="Novák" {...rP("last_name")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.usernameLabel")}</label>
|
||||
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.username ?? ""} readOnly disabled />
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/65">Město</label>
|
||||
<input className={inputClass} placeholder="Praha" {...rP("city")} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.emailLabel")}</label>
|
||||
<input className={inputClass + " opacity-50 cursor-not-allowed"} value={user?.email ?? ""} readOnly disabled />
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/65">Telefon</label>
|
||||
<input className={inputClass} placeholder="+420 ..." {...rP("phone_number")} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.cityLabel")}</label>
|
||||
<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 type="submit" disabled={pSubmit} leftIcon={pSubmit ? <Spinner size={14} /> : undefined}>
|
||||
{pSubmit ? "Ukládám…" : "Uložit profil"}
|
||||
</Button>
|
||||
</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" && (
|
||||
<form onSubmit={handlePassword(onPasswordSubmit)} className="flex flex-col gap-4">
|
||||
<div className="text-sm font-semibold text-brand-text">{t("accountSettings.changePassword")}</div>
|
||||
<FormErrorBanner message={passwordRootError} />
|
||||
{passwordSuccess && (
|
||||
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400">
|
||||
{t("accountSettings.passwordChanged")}
|
||||
<div className="flex flex-col gap-8">
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<Section title="Změna hesla" subtitle="Heslo musí mít alespoň 8 znaků, velké i malé písmeno a číslici." />
|
||||
<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>
|
||||
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.currentPasswordLabel")}</label>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClass}
|
||||
placeholder="••••••••"
|
||||
{...regPassword("current_password", { required: t("accountSettings.required") })}
|
||||
/>
|
||||
{passwordForm.formState.errors.current_password && (
|
||||
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.current_password.message}</p>
|
||||
{!deleteOpen ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-brand-text/50">
|
||||
Trvale deaktivuje váš účet. Tato akce je nevratná.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
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"
|
||||
>
|
||||
<FiTrash2 size={13} /> Smazat účet
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<DeleteAccountForm
|
||||
inputClass={inputClass}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onDeleted={() => setDeleteOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
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
|
||||
* (Vite dev proxy → backend, or nginx in production).
|
||||
*
|
||||
* - Full URLs (http/https): strip the origin, keep only the path.
|
||||
* - Relative paths without a leading slash: add one.
|
||||
* Normalises a media URL:
|
||||
* - External hosts (S3, CDN): returned as-is.
|
||||
* - Backend / same-origin URLs: strip origin so the request goes through
|
||||
* the Vite dev proxy or nginx in production.
|
||||
* - blob: / data: URLs: returned unchanged.
|
||||
* - null / undefined / empty: returns null.
|
||||
*/
|
||||
@@ -11,11 +18,12 @@ export function mediaUrl(src: string | null | undefined): string | null {
|
||||
if (!src) return null;
|
||||
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
|
||||
try {
|
||||
// Full URL — strip origin so the request goes through the proxy/nginx
|
||||
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 || "");
|
||||
} catch {
|
||||
// Already a relative path
|
||||
return src.startsWith("/") ? src : `/${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user