done last commit before merging - fixed media URLSs S3

This commit is contained in:
2026-06-12 00:56:01 +02:00
parent f4c4a8bfd1
commit 44e77e7744
19 changed files with 1478 additions and 193 deletions

View 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),
),
]

View File

@@ -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:

View File

@@ -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)

View File

@@ -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(

View File

@@ -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'),

View File

@@ -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__)
@@ -433,4 +436,206 @@ class PasswordResetConfirmView(APIView):
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

View File

@@ -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,
} }

View 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>

View File

@@ -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

View File

@@ -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 />}>

View File

@@ -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

View 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>
);
}

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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) => (

View File

@@ -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,53 +315,45 @@ 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")}>
<FiLock size={16} /> {t("accountSettings.tabSecurity")}
</button> </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"> <div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/40">
{bannerUploading ? ( {bannerUploading ? <Spinner size={22} /> : (
<Spinner size={22} /> <div className="flex flex-col items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
) : (
<div className="flex flex-col items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100">
<FiImage size={20} className="text-white" /> <FiImage size={20} className="text-white" />
<span className="text-xs text-white/90">{t("accountSettings.changeBanner")}</span> <span className="text-xs text-white/90">Změnit banner</span>
</div> </div>
)} )}
</div> </div>
</div> </div>
<input ref={bannerInputRef} type="file" accept="image/*" className="hidden" onChange={handleBannerChange} /> <input ref={bannerRef} type="file" accept="image/*" className="hidden" onChange={handleBanner} />
{/* Avatar overlapping banner bottom-left */}
<div className="absolute -bottom-8 left-4"> <div className="absolute -bottom-8 left-4">
<div className="relative rounded-full ring-4 ring-brand-bg"> <div className="relative rounded-full ring-4 ring-brand-bg">
<Avatar name={displayName} src={avatarSrc} size={64} /> <Avatar name={displayName} src={avatarSrc} size={64} />
@@ -211,120 +364,248 @@ export default function AccountSettingsPage() {
)} )}
<button <button
type="button" type="button"
onClick={() => avatarInputRef.current?.click()} 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 transition-opacity" 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} /> <FiCamera size={11} />
</button> </button>
</div> </div>
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} /> <input ref={avatarRef} type="file" accept="image/*" className="hidden" onChange={handleAvatar} />
</div> </div>
</div> </div>
<p className="text-xs text-brand-text/40">{t("accountSettings.fileHint")}</p> <p className="text-xs text-brand-text/35 -mt-4">Maximální velikost souboru: 5 MB. Formáty: JPG, PNG, WebP.</p>
</div>
{/* Profile form */} <hr className="border-brand-lines/10" />
<form onSubmit={handleProfile(onProfileSubmit)} className="flex flex-col gap-4">
<FormErrorBanner message={profileRootError} /> <Section title="Základní informace" subtitle="Zobrazené jméno a kontaktní údaje." />
{profileSuccess && (
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400"> <form onSubmit={hP(onProfileSubmit)} className="flex flex-col gap-4">
{t("accountSettings.profileSaved")} <FormErrorBanner message={profileError} />
</div> {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>
<Button type="submit" disabled={pSubmit} leftIcon={pSubmit ? <Spinner size={14} /> : undefined}>
{pSubmit ? "Ukládám…" : "Uložit profil"}
</Button>
</form>
</>
)}
{/* ── Úč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> <div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.cityLabel")}</label> <label className="mb-1 block text-xs font-medium text-brand-text/65">Nové uživatelské jméno</label>
<input className={inputClass} placeholder={t("accountSettings.cityPlaceholder")} {...regProfile("city")} /> <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> </div>
<div> {turnstileEnabled && <div ref={tUsernameRef} className="flex justify-center" />}
<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}> <Button
{profileSubmitting ? t("accountSettings.saving") : t("accountSettings.saveProfile")} type="submit"
variant="secondary"
disabled={uSubmit || !tUsernameToken}
leftIcon={uSubmit ? <Spinner size={14} /> : undefined}
>
{uSubmit ? "Ukládám…" : "Změnit jméno"}
</Button> </Button>
</form> </form>
</div> </div>
)}
{/* ── Security tab ── */} <hr className="border-brand-lines/10" />
{tab === "security" && (
<form onSubmit={handlePassword(onPasswordSubmit)} className="flex flex-col gap-4"> {/* Email */}
<div className="text-sm font-semibold text-brand-text">{t("accountSettings.changePassword")}</div> <div>
<FormErrorBanner message={passwordRootError} /> <Section
{passwordSuccess && ( title="E-mailová adresa"
<div className="rounded-xl bg-green-500/10 border border-green-500/20 px-3 py-2 text-sm text-green-400"> subtitle={`Aktuálně: ${user?.email} · Po odeslání potvrďte novou adresu kliknutím na odkaz v e-mailu.`}
{t("accountSettings.passwordChanged")} />
</div> <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> <div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.currentPasswordLabel")}</label> <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" && (
<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 <input
type="password" type="password"
className={inputClass} className={inputClass}
placeholder="••••••••" placeholder="••••••••"
{...regPassword("current_password", { required: t("accountSettings.required") })} autoComplete="current-password"
{...rPw("current_password", { required: true })}
/> />
{passwordForm.formState.errors.current_password && ( {passwordForm.formState.errors.current_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.current_password.message}</p> <p className="mt-1 text-xs text-red-400">Povinné pole.</p>
)} )}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.newPasswordLabel")}</label> <label className="mb-1 block text-xs font-medium text-brand-text/65">Nové heslo</label>
<input <input
type="password" type="password"
className={inputClass} className={inputClass}
placeholder="••••••••" placeholder="••••••••"
{...regPassword("new_password", { required: t("accountSettings.required") })} autoComplete="new-password"
{...rPw("new_password", { required: true })}
/> />
{passwordForm.formState.errors.new_password && ( {passwordForm.formState.errors.new_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.new_password.message}</p> <p className="mt-1 text-xs text-red-400">Povinné pole.</p>
)} )}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-brand-text/70">{t("accountSettings.confirmPasswordLabel")}</label> <label className="mb-1 block text-xs font-medium text-brand-text/65">Potvrdit nové heslo</label>
<input <input
type="password" type="password"
className={inputClass} className={inputClass}
placeholder="••••••••" placeholder="••••••••"
{...regPassword("confirm_password", { required: t("accountSettings.required") })} autoComplete="new-password"
{...rPw("confirm_password", { required: true })}
/> />
{passwordForm.formState.errors.confirm_password && ( {passwordForm.formState.errors.confirm_password && (
<p className="mt-1 text-xs text-red-400">{passwordForm.formState.errors.confirm_password.message}</p> <p className="mt-1 text-xs text-red-400">
{passwordForm.formState.errors.confirm_password.message ?? "Povinné pole."}
</p>
)} )}
</div> </div>
<Button type="submit" disabled={passwordSubmitting} leftIcon={passwordSubmitting ? <Spinner size={14} /> : undefined}> <Button
{passwordSubmitting ? t("accountSettings.saving") : t("accountSettings.changePasswordBtn")} type="submit"
disabled={pwSubmit}
leftIcon={pwSubmit ? <Spinner size={14} /> : <FiLock size={14} />}
>
{pwSubmit ? "Ukládám…" : "Změnit heslo"}
</Button> </Button>
</form> </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>
{!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>
)} )}
</div> </div>
</div> </div>

View 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>
);
}

View File

@@ -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}`;
} }
} }