diff --git a/backend/.env.example b/backend/.env.example index bc5e29484..0049e7b8e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,6 +13,10 @@ AWS_SES_REGION_NAME=region_name AWS_SES_REGION_ENDPOINT=region_endpoint DEFAULT_FROM_EMAIL=email +# Cloudflare Turnstile secret key for CAPTCHA verification. This placeholder is the test +# key provided by Cloudflare which always returns success on verification for development. +CF_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA + # The URL of the main site, used for generating links (no trailing slash) BASE_URL=https://example.com diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index a219c9a28..2f414cb03 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -19,6 +19,7 @@ from api.decorators import ( api_endpoint, require_account_auth, + require_captcha, validate_json_input, validate_output, ) @@ -51,6 +52,7 @@ @api_endpoint("POST") +@require_captcha @validate_json_input(RegisterAccountSerializer) @validate_output(MessageOutputSerializer) def register(request): @@ -307,6 +309,7 @@ def check_account_auth(request): @api_endpoint("POST") +@require_captcha @validate_json_input(EmailSerializer) @validate_output(MessageOutputSerializer) def start_password_reset(request): diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index c0a785e9d..ca0388816 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -15,6 +15,7 @@ api_endpoint, check_auth, require_auth, + require_captcha, validate_json_input, validate_output, validate_query_param_input, @@ -46,6 +47,7 @@ class InvalidTimeslotError(Exception): @api_endpoint("POST") +@require_captcha @require_auth @validate_json_input(AvailabilityAddSerializer) @validate_output(MessageOutputSerializer) diff --git a/backend/api/decorators.py b/backend/api/decorators.py index 14d2c5286..1091fbf96 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -2,6 +2,7 @@ import logging import uuid +import requests from django.db import DatabaseError, transaction from rest_framework import serializers from rest_framework.decorators import api_view @@ -11,6 +12,9 @@ from api.models import UserAccount, UserSession from api.settings import ( ACCOUNT_COOKIE_NAME, + CF_TURNSTILE_SECRET_KEY, + CF_TURNSTILE_VERIFY_URL, + DEBUG, GENERIC_ERR_RESPONSE, GUEST_COOKIE_NAME, ThrottleScopes, @@ -19,6 +23,7 @@ RateLimitError, check_rate_limit, delete_session_cookie, + get_client_ip_address, get_metadata, get_session, set_session_cookie, @@ -517,3 +522,49 @@ def decorator(func): return func return decorator + + +def require_captcha(func): + """ + A decorator to check if the request has a valid Cloudflare Turnstile token. + + If the token is invalid, an error response will be returned. + """ + + @functools.wraps(func) + def wrapper(request, *args, **kwargs): + client_token = ( + request.data.get("captcha_token") + if isinstance(request.data, dict) + else None + ) + if not client_token: + return Response( + {"error": {"general": ["CAPTCHA token is required."]}}, status=400 + ) + + # Assemble payload, including client IP if possible for extra security + payload = { + "secret": CF_TURNSTILE_SECRET_KEY, + "response": client_token, + } + if client_ip := get_client_ip_address(request): + payload["remoteip"] = client_ip + + try: + # Verify the token with Cloudflare Turnstile + response = requests.post(CF_TURNSTILE_VERIFY_URL, data=payload, timeout=5) + response.raise_for_status() + result = response.json() + if not result.get("success"): + return Response( + {"error": {"general": ["CAPTCHA verification failed."]}}, status=400 + ) + except Exception as e: + logger.warning("CAPTCHA verification had a problem: %s", e) + return GENERIC_ERR_RESPONSE + + return func(request, *args, **kwargs) + + get_metadata(wrapper).captcha_required = True + return wrapper diff --git a/backend/api/docs/utils.py b/backend/api/docs/utils.py index 20623732c..afdd920f2 100644 --- a/backend/api/docs/utils.py +++ b/backend/api/docs/utils.py @@ -46,6 +46,8 @@ def get_readable_field_name(field_name): return "string" case "RegexField": return "string" + case "UUIDField": + return "string" case _: return "object" diff --git a/backend/api/docs/views.py b/backend/api/docs/views.py index f9cbaabbe..59e29c130 100644 --- a/backend/api/docs/views.py +++ b/backend/api/docs/views.py @@ -17,6 +17,7 @@ class EndpointSerializer(serializers.Serializer): output_format = serializers.JSONField(allow_null=True) min_auth_required = serializers.CharField(allow_null=True) rate_limit = serializers.CharField(allow_null=True) + captcha_required = serializers.BooleanField() class DocsSerializer(serializers.Serializer): @@ -49,6 +50,7 @@ def get_docs(request): ), "min_auth_required": metadata.min_auth_required, "rate_limit": metadata.rate_limit, + "captcha_required": metadata.captcha_required, } ) return Response({"endpoints": endpoints}) diff --git a/backend/api/event/utils.py b/backend/api/event/utils.py index b8c1453c8..d8234fbfc 100644 --- a/backend/api/event/utils.py +++ b/backend/api/event/utils.py @@ -33,6 +33,7 @@ def check_custom_code(code): "api", "dashboard", "forgot-password", + "guest-import", "login", "new-event", "settings", diff --git a/backend/api/event/views.py b/backend/api/event/views.py index 3e3c15c31..81f132e16 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -16,6 +16,7 @@ api_endpoint, check_auth, require_auth, + require_captcha, sse_endpoint, validate_json_input, validate_output, @@ -75,6 +76,7 @@ @api_endpoint("POST") +@require_captcha @require_auth @validate_json_input(DateEventCreateSerializer) @validate_output(EventCodeSerializer) @@ -138,6 +140,7 @@ def create_date_event(request): @api_endpoint("POST") +@require_captcha @require_auth @validate_json_input(WeekEventCreateSerializer) @validate_output(EventCodeSerializer) diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py new file mode 100644 index 000000000..6b6e14deb --- /dev/null +++ b/backend/api/guest_import/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + + +class GuestDataSummarySerializer(serializers.Serializer): + created_events = serializers.IntegerField() + participated_events = serializers.IntegerField() + + +class GuestEventSerializer(serializers.Serializer): + url_code = serializers.CharField(allow_null=True) + public_id = serializers.UUIDField() + title = serializers.CharField() + + +class GuestParticipationSerializer(serializers.Serializer): + url_code = serializers.CharField(allow_null=True) + public_id = serializers.UUIDField() + title = serializers.CharField() + guest_display_name = serializers.CharField() + account_display_name = serializers.CharField(allow_null=True) + + +class GuestDataSerializer(serializers.Serializer): + created_events = serializers.ListField( + child=GuestEventSerializer(), + allow_empty=True, + ) + participated_events = serializers.ListField( + child=GuestParticipationSerializer(), + allow_empty=True, + ) + + +class GuestImportDataSerializer(serializers.Serializer): + availability_choices = serializers.DictField( + required=True, + child=serializers.ChoiceField(required=True, choices=["guest", "account"]), + allow_empty=True, + ) diff --git a/backend/api/guest_import/urls.py b/backend/api/guest_import/urls.py new file mode 100644 index 000000000..a4b484349 --- /dev/null +++ b/backend/api/guest_import/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("get-summary/", views.get_summary), + path("get-data/", views.get_data), + path("import-data/", views.import_data), +] diff --git a/backend/api/guest_import/utils.py b/backend/api/guest_import/utils.py new file mode 100644 index 000000000..6ebe44448 --- /dev/null +++ b/backend/api/guest_import/utils.py @@ -0,0 +1,38 @@ +import logging + +from django.db import transaction + +from api.models import UserSession +from api.settings import GUEST_COOKIE_NAME +from api.utils import get_session, set_session_cookie, update_session_metadata + +logger = logging.getLogger("api") + + +def get_guest_account(request, response): + """ + Returns the guest account associated with the request, refreshing the session token + cookie and updating the metadata on the session. + + If there is no guest account, this will return `None`. + + This does NOT make the guest user available on the `request` object. + """ + guest_token = request.COOKIES.get(GUEST_COOKIE_NAME) + + if guest_token: + logger.debug("Guest session token: %s", guest_token) + try: + with transaction.atomic(): + session = get_session(guest_token) + if not session: + raise UserSession.DoesNotExist + + update_session_metadata(request, session) + except UserSession.DoesNotExist: + logger.info("Guest session expired.") + else: + set_session_cookie(response, GUEST_COOKIE_NAME, session.session_token, True) + return session.user_account + + return None diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py new file mode 100644 index 000000000..e852628c9 --- /dev/null +++ b/backend/api/guest_import/views.py @@ -0,0 +1,231 @@ +import logging + +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from rest_framework.response import Response + +from api.decorators import ( + api_endpoint, + require_account_auth, + validate_json_input, + validate_output, +) +from api.guest_import.serializers import ( + GuestDataSerializer, + GuestDataSummarySerializer, + GuestImportDataSerializer, +) +from api.guest_import.utils import get_guest_account +from api.models import ( + EventParticipant, + UserEvent, +) +from api.utils import MessageOutputSerializer + +logger = logging.getLogger("api") + + +@api_endpoint("GET") +@require_account_auth +@validate_output(GuestDataSummarySerializer) +def get_summary(request): + """ + Returns counts of events created and participated in by the guest user. + """ + response = Response() + + guest_user = get_guest_account(request, response) + + if guest_user is None: + response.data = { + "created_events": 0, + "participated_events": 0, + } + response.status_code = 200 + return response + + events = UserEvent.objects.filter(user_account=guest_user).count() + + participations = EventParticipant.objects.filter(user_account=guest_user).count() + + response.data = { + "created_events": events, + "participated_events": participations, + } + response.status_code = 200 + + return response + + +@api_endpoint("GET") +@require_account_auth +@validate_output(GuestDataSerializer) +def get_data(request): + """ + Returns brief information about events created and participated in by the guest user. + """ + response = Response() + + guest_user = get_guest_account(request, response) + account_user = request.user + + if guest_user is None: + response.data = { + "created_events": [], + "participated_events": [], + } + response.status_code = 200 + return response + + def get_url_code(event): + try: + return event.url_code.url_code + except ObjectDoesNotExist: + return None + + events = UserEvent.objects.filter(user_account=guest_user).select_related( + "url_code" + ) + created_events = [ + { + "url_code": get_url_code(event), + "public_id": event.public_id, + "title": event.title, + } + for event in events + ] + + participations = EventParticipant.objects.filter( + user_account=guest_user, + ).select_related("user_event__url_code") + participated_events = [ + { + "url_code": get_url_code(participation.user_event), + "public_id": participation.user_event.public_id, + "title": participation.user_event.title, + "guest_display_name": participation.display_name, + "account_display_name": None, + } + for participation in participations + ] + + conflicts = EventParticipant.objects.filter( + user_account=account_user, + user_event__in=participations.values_list("user_event", flat=True), + ).select_related("user_event") + participation_map = {p["public_id"]: p for p in participated_events} + for conflict in conflicts: + participation = participation_map.get(conflict.user_event.public_id) + if participation: + participation["account_display_name"] = conflict.display_name + logger.info( + "%s conflicted events found during guest import for user: %s", + len(conflicts), + account_user.email, + ) + + response.data = { + "created_events": created_events, + "participated_events": participated_events, + } + response.status_code = 200 + + return response + + +@api_endpoint("POST") +@require_account_auth +@validate_json_input(GuestImportDataSerializer) +@validate_output(MessageOutputSerializer) +def import_data(request): + """ + Transfers ownership of events and availabilities created by the guest to the account. + + The body of the request should contain a mapping of event's public ID to the user's + chosen submission to keep, for each event the guest had submitted availability to. If + there is no conflict, "guest" should be specified. + + Every conflicted submission that is not kept will be deleted when calling this + endpoint. + """ + availability_choices = request.data.get("availability_choices", {}) + + response = Response() + + guest_user = get_guest_account(request, response) + account_user = request.user + + def no_data_found(): + response.data = { + "error": {"general": ["No guest data found."]}, + } + response.status_code = 400 + return response + + if guest_user is None: + return no_data_found() + + with transaction.atomic(): + # Check if the guest has any data to import + guest_events = UserEvent.objects.filter(user_account=guest_user) + guest_submissions = EventParticipant.objects.filter( + user_account=guest_user + ).select_related("user_event") + if not guest_events.exists() and not guest_submissions.exists(): + logger.warning( + "No guest data found for import attempt for user: %s", + account_user.email, + ) + return no_data_found() + + # Check if all the guest user's submissions are accounted for in the choices + if set(availability_choices.keys()) != set( + str(submission.user_event.public_id) for submission in guest_submissions + ): + logger.warning( + "Availability choices do not match guest submissions for user: %s", + account_user.email, + ) + response.data = { + "error": { + "availability_choices": [ + "Availability choices do not match guest submissions.", + ] + } + } + response.status_code = 400 + return response + + # Transfer event ownership + UserEvent.objects.filter(user_account=guest_user).update( + user_account=account_user + ) + + # === DELETE DISCARDED SUBMISSIONS === + account_chosen = [] + guest_chosen = [] + for public_id, choice in availability_choices.items(): + match choice: + case "account": + account_chosen.append(public_id) + case "guest": + guest_chosen.append(public_id) + + # Start by removing the guest's submissions from events that have "account" specified + EventParticipant.objects.filter( + user_account=guest_user, user_event__public_id__in=account_chosen + ).delete() + # Then remove the account's submissions from events that have "guest" specified + EventParticipant.objects.filter( + user_account=account_user, user_event__public_id__in=guest_chosen + ).delete() + + # Then of all the remaining guest submissions transfer ownership to the account + EventParticipant.objects.filter(user_account=guest_user).update( + user_account=account_user + ) + + response.data = {"message": ["Guest data imported successfully."]} + response.status_code = 200 + + return response diff --git a/backend/api/migrations/0034_userevent_public_id.py b/backend/api/migrations/0034_userevent_public_id.py new file mode 100644 index 000000000..828882545 --- /dev/null +++ b/backend/api/migrations/0034_userevent_public_id.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2026-07-09 17:46 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0033_urlcode_unique_case_insensitive_url_code"), + ] + + operations = [ + migrations.AddField( + model_name="userevent", + name="public_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + ] diff --git a/backend/api/migrations/0035_populate_public_id.py b/backend/api/migrations/0035_populate_public_id.py new file mode 100644 index 000000000..a94f77315 --- /dev/null +++ b/backend/api/migrations/0035_populate_public_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2026-07-09 17:47 + +import uuid + +from django.db import migrations + + +def gen_uuid(apps, _): + UserEvent = apps.get_model("api", "UserEvent") + for row in UserEvent.objects.iterator(): + row.public_id = uuid.uuid4() + row.save(update_fields=["public_id"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0034_userevent_public_id"), + ] + + operations = [ + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + ] diff --git a/backend/api/migrations/0036_apply_unique_constraint.py b/backend/api/migrations/0036_apply_unique_constraint.py new file mode 100644 index 000000000..e44d54684 --- /dev/null +++ b/backend/api/migrations/0036_apply_unique_constraint.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2026-07-09 17:47 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0035_populate_public_id"), + ] + + operations = [ + migrations.AlterField( + model_name="userevent", + name="public_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 813fd6121..f21023fca 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -88,6 +88,7 @@ class UserLogin(models.Model): class UserEvent(models.Model): user_event_id = models.AutoField(primary_key=True) + public_id = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) user_account = models.ForeignKey( UserAccount, on_delete=models.CASCADE, related_name="events" ) diff --git a/backend/api/settings.py b/backend/api/settings.py index c15ebe4c2..018d8643a 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -118,9 +118,9 @@ class ThrottleScopes: "DEFAULT_AUTHENTICATION_CLASSES": [], "DEFAULT_THROTTLE_RATES": { ThrottleScopes.GLOBAL.key: "300/min", - ThrottleScopes.USER_ACCOUNT_CREATION.key: "10/hour", + ThrottleScopes.USER_ACCOUNT_CREATION.key: "100/min", ThrottleScopes.RESEND_EMAIL.key: "20/hour", - ThrottleScopes.GUEST_ACCOUNT_CREATION.key: "10/min", + ThrottleScopes.GUEST_ACCOUNT_CREATION.key: "100/min", ThrottleScopes.LOGIN.key: "30/hour", ThrottleScopes.PASSWORD_RESET.key: "10/hour", ThrottleScopes.EVENT_CREATION.key: "25/hour", @@ -129,6 +129,10 @@ class ThrottleScopes: }, } +# Cloudflare Turnstile secret key +CF_TURNSTILE_SECRET_KEY = env("CF_TURNSTILE_SECRET_KEY") +CF_TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + SESS_EXP_SECONDS = 3600 # 1 hour LONG_SESS_EXP_SECONDS = 31536000 # 1 year diff --git a/backend/api/urls.py b/backend/api/urls.py index 4f12fec0f..6c4a104ef 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -7,4 +7,5 @@ path("availability/", include("api.availability.urls")), path("dashboard/", include("api.dashboard.urls")), path("account/", include("api.account.urls")), + path("guest-import/", include("api.guest_import.urls")), ] diff --git a/backend/api/utils.py b/backend/api/utils.py index 76e580dda..1cb17d7e0 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -41,6 +41,7 @@ def __init__(self): self.output_serializer_class = None self.rate_limit = None self.min_auth_required = None + self.captcha_required = False def get_metadata(func): diff --git a/backend/requirements.txt b/backend/requirements.txt index ba4dbd214..5da21fb57 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,8 @@ billiard==4.2.1 boto3==1.39.14 botocore==1.39.14 celery==5.5.3 +certifi==2026.6.17 +charset-normalizer==3.4.9 click==8.2.1 click-didyoumean==0.3.1 click-plugins==1.1.1.2 @@ -19,6 +21,7 @@ django-ipware==7.0.1 django-ses==4.4.0 djangorestframework==3.16.0 h11==0.16.0 +idna==3.18 isort==8.0.1 jmespath==1.0.1 kombu==5.5.4 @@ -31,6 +34,7 @@ python-ipware==3.0.0 PyYAML==6.0.3 redis==6.2.0 regex==2026.4.4 +requests==2.34.2 s3transfer==0.13.1 six==1.17.0 sqlparse==0.5.3 diff --git a/frontend/.env.example b/frontend/.env.example index 26ef4402c..b54cc8475 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,3 +4,7 @@ NEXT_PUBLIC_DEBUG=true # The API URL for both the client and the Next.js server to communicate with the backend # Note the lack of a trailing slash NEXT_PUBLIC_API_URL=https://example.com + +# Cloudflare Turnstile site key for CAPTCHA verification. This placeholder is the test +# key provided by Cloudflare which always returns success on verification for development. +NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY=1x00000000000000000000AA diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4f8d7778..e47352913 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "hasInstallScript": true, "dependencies": { + "@marsidev/react-turnstile": "1.5.3", "@microsoft/fetch-event-source": "^2.0.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -1021,6 +1022,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.3.tgz", + "integrity": "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@microsoft/eslint-formatter-sarif": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@microsoft/eslint-formatter-sarif/-/eslint-formatter-sarif-3.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 14a5a9d52..1553d9cfb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "postinstall": "patch-package" }, "dependencies": { + "@marsidev/react-turnstile": "1.5.3", "@microsoft/fetch-event-source": "^2.0.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index 4575adf91..db01aa51a 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; +import Captcha from "@/components/captcha"; import AuthPageLayout from "@/components/layout/auth-page"; import MessagePage from "@/components/layout/message-page"; import LinkText from "@/components/link-text"; @@ -20,6 +21,10 @@ export default function Page() { const [email, setEmail] = useState(""); const [emailSent, setEmailSent] = useState(false); + // CAPTCHA STATES + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); + // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -36,15 +41,24 @@ export default function Page() { handleError("email", MESSAGES.ERROR_EMAIL_MISSING); return false; } + if (!captchaToken) { + handleError("captcha", MESSAGES.ERROR_CAPTCHA_FAILED); + return false; + } try { - await clientPost(ROUTES.auth.startPasswordReset, { email }); + await clientPost(ROUTES.auth.startPasswordReset, { + email, + captcha_token: captchaToken, + }); setEmailSent(true); return true; } catch (e) { const error = e as ApiErrorResponse; if (error.rateLimited) { handleError("rate_limit", error.formattedMessage); + } else if (error.captchaFailed) { + handleError("captcha", MESSAGES.ERROR_CAPTCHA_FAILED); } else if (error.formattedMessage.includes("Email:")) { handleError("email", error.formattedMessage.split("Email:")[1].trim()); } else { @@ -86,13 +100,22 @@ export default function Page() { ]} rateLimitError={errors.rate_limit} > -
- + handleError("captcha", "")} + onInitError={() => setCaptchaInitError(true)} /> +
+ +
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index d95350698..9b0a55b86 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -10,6 +10,7 @@ import AuthPageLayout from "@/components/layout/auth-page"; import LinkText from "@/components/link-text"; import TextInputField from "@/components/text-input-field"; import ActionButton from "@/features/button/components/action"; +import { DONT_SHOW_AGAIN_KEY } from "@/features/guest-import/constants"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; @@ -24,7 +25,7 @@ export default function Page() { const router = useRouter(); const searchParams = useSearchParams(); - const callbackUrl = getSafeRedirectUrl(searchParams.get("callbackUrl")); + const callbackUrl = searchParams.get("callbackUrl"); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -54,12 +55,19 @@ export default function Page() { } try { + const dontShowGuestImport = + localStorage.getItem(DONT_SHOW_AGAIN_KEY) === "true"; + const redirectUrl = getSafeRedirectUrl( + callbackUrl, + dontShowGuestImport ? undefined : "/guest-import/login", + ); + await clientPost(ROUTES.auth.login, { email, password, remember_me: rememberMe, }); - router.push(callbackUrl); + router.push(redirectUrl); router.refresh(); return true; } catch (e) { diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 79c214c77..9fef6ef4b 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import Captcha from "@/components/captcha"; import AuthPageLayout from "@/components/layout/auth-page"; import LinkText from "@/components/link-text"; import TextInputField from "@/components/text-input-field"; @@ -24,6 +25,10 @@ export default function Page() { const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); const router = useRouter(); + // CAPTCHA STATES + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); + // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -67,9 +72,17 @@ export default function Page() { handleError("confirmPassword", MESSAGES.ERROR_PASSWORD_MISMATCH); return false; } + if (!captchaToken) { + handleError("captcha", MESSAGES.ERROR_CAPTCHA_FAILED); + return false; + } try { - await clientPost(ROUTES.auth.register, { email, password }); + await clientPost(ROUTES.auth.register, { + email, + password, + captcha_token: captchaToken, + }); sessionStorage.setItem("register_email", email); router.push("/register/email-sent"); return true; @@ -77,6 +90,8 @@ export default function Page() { const error = e as ApiErrorResponse; if (error.rateLimited) { handleError("rate_limit", error.formattedMessage); + } else if (error.captchaFailed) { + handleError("captcha", MESSAGES.ERROR_CAPTCHA_FAILED); } else if (error.formattedMessage.includes("Email:")) { handleError("email", error.formattedMessage.split("Email:")[1].trim()); } else if (error.formattedMessage.includes("Password:")) { @@ -143,13 +158,22 @@ export default function Page() { />, ]} > -
- + handleError("captcha", "")} + onInitError={() => setCaptchaInitError(true)} /> +
+ +
diff --git a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx index 8fb4afac0..5774536b0 100644 --- a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx @@ -6,6 +6,7 @@ import { parseISO } from "date-fns"; import { useRouter } from "next/navigation"; import { useDebouncedCallback } from "use-debounce"; +import Captcha from "@/components/captcha"; import Checkbox from "@/components/checkbox"; import MobileFooterIsland from "@/components/mobile-footer-island"; import TextInputField from "@/components/text-input-field"; @@ -55,6 +56,10 @@ export default function ClientPage({ ); const { displayName, timeZone, userAvailability } = state; + // CAPTCHA STATES + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); + // TOASTS AND ERROR STATES const { addToast } = useToast(); const [errors, setErrors] = useState>({}); @@ -179,6 +184,14 @@ export default function ClientPage({ } } + if (!captchaToken) { + setErrors((prev) => ({ + ...prev, + captcha: MESSAGES.ERROR_CAPTCHA_FAILED, + })); + return false; + } + // Save the default name if checkbox checked if (saveDefaultName) { if (session.isLoggedIn) { @@ -208,6 +221,7 @@ export default function ClientPage({ display_name: displayName, availability: payload_availability, time_zone: timeZone, + captcha_token: captchaToken, }; try { @@ -221,6 +235,11 @@ export default function ClientPage({ ...prev, rate_limit: error.formattedMessage || MESSAGES.ERROR_RATE_LIMIT, })); + } else if (error.captchaFailed) { + setErrors((prev) => ({ + ...prev, + captcha: MESSAGES.ERROR_CAPTCHA_FAILED, + })); } else { addToast("error", error.formattedMessage); } @@ -246,6 +265,7 @@ export default function ClientPage({ } onClick={handleSubmitAvailability} loadOnSuccess + disabled={captchaInitError} /> ); @@ -258,6 +278,20 @@ export default function ClientPage({ {errors.rate_limit} )} + {/* CAPTCHA + Error */} + + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.captcha; + return newErrors; + }) + } + onInitError={() => setCaptchaInitError(true)} + /> + {/* Header and Button Row */}

{eventName}

diff --git a/frontend/src/app/dashboard/page-client.tsx b/frontend/src/app/dashboard/page-client.tsx index aac3c7afd..aabf6106e 100644 --- a/frontend/src/app/dashboard/page-client.tsx +++ b/frontend/src/app/dashboard/page-client.tsx @@ -81,20 +81,17 @@ export default function ClientPage({

Dashboard

{!logged_in && ( - +
- This data is only available from this browser.{" "} + This data is only available from this browser,{" "} - Create an account + create an account {" "} - to sync data across devices. -
-
- Currently, guest data cannot be transferred to an account. Keep an - eye out for updates! + to sync across devices. You can import this data into your account + at any time in account settings.
)} diff --git a/frontend/src/app/guest-import/import/loading.tsx b/frontend/src/app/guest-import/import/loading.tsx new file mode 100644 index 000000000..98c580f76 --- /dev/null +++ b/frontend/src/app/guest-import/import/loading.tsx @@ -0,0 +1,41 @@ +import HeaderSpacer from "@/features/header/components/header-spacer"; + +export default function Loading() { + return ( +
+ +

Guest Import Review

+ +
+ +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/guest-import/import/page-client.tsx b/frontend/src/app/guest-import/import/page-client.tsx new file mode 100644 index 000000000..ed0fe9670 --- /dev/null +++ b/frontend/src/app/guest-import/import/page-client.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import MobileFooterIsland from "@/components/mobile-footer-island"; +import ActionButton from "@/features/button/components/action"; +import LinkButton from "@/features/button/components/link"; +import HeaderSpacer from "@/features/header/components/header-spacer"; +import Selector from "@/features/selector/components/selector"; +import { Banner, useToast } from "@/features/system-feedback"; +import Tooltip from "@/features/system-feedback/tooltip/base"; +import { clientPost } from "@/lib/utils/api/client-fetch"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { GuestData } from "@/lib/utils/api/types"; +import { cn } from "@/lib/utils/classname"; + +type AvailabilityImportChoice = "guest" | "account"; +type ImportPayload = { + [public_id: string]: AvailabilityImportChoice; +}; + +export default function ClientPage({ guestData }: { guestData: GuestData }) { + const { addToast } = useToast(); + const router = useRouter(); + + const [importPayload, setImportPayload] = useState<{ + [public_id: string]: AvailabilityImportChoice; + }>( + guestData.participated_events.reduce((acc, event) => { + if (event.account_display_name === null) { + acc[event.public_id] = "guest"; + } + return acc; + }, {} as ImportPayload), + ); + + const unresolvedConflicts = + guestData.participated_events.length - Object.keys(importPayload).length; + const conflictedEvents = useMemo(() => { + return new Set( + guestData.participated_events + .filter((event) => event.account_display_name !== null) + .map((event) => event.public_id), + ); + }, [guestData.participated_events]); + + const resolveConflict = ( + public_id: string, + choice: AvailabilityImportChoice, + ) => { + setImportPayload((prev) => ({ + ...prev, + [public_id]: choice, + })); + }; + + const submitImport = async () => { + try { + await clientPost(ROUTES.guestImport.importData, { + availability_choices: importPayload, + }); + addToast("success", "Guest data imported successfully."); + router.push("/dashboard"); + } catch { + addToast( + "error", + "Something went wrong with your request. Please refresh the page and try again.", + ); + } + }; + + const cancelButton = ( + + ); + + const importButton = ( + 0} + tooltip={ + unresolvedConflicts > 0 + ? "Please resolve conflicts before importing." + : undefined + } + /> + ); + + const actionText = ( +

+ Are you sure you want to import this guest data? This action cannot be + undone. +

+ ); + + const noneText = ( +
None
+ ); + + return ( +
+ +
+

Guest Import

+
+ {cancelButton} + {importButton} +
+
+

{actionText}

+
+ + {guestData.created_events.length === 0 && noneText} + {guestData.created_events.map((event) => ( + + ))} + + + {unresolvedConflicts > 0 && ( + + Conflicts occur when you{"'"}ve added availability to the same + event both as a guest and from your account. You must{" "} + choose which submission to keep{" "} + when resolving each conflict. + + )} + + {guestData.participated_events.length === 0 && noneText} + + {guestData.participated_events.map((event) => { + const choice = importPayload[event.public_id] ?? null; + const hasConflict = conflictedEvents.has(event.public_id); + + return ( + + {hasConflict ? ( +
+
+ + +
+
+ + resolveConflict(event.public_id, choice) + } + options={[ + { label: "Keep Guest", value: "guest" }, + { + label: "Keep Account", + value: "account", + }, + ]} + value={choice} + placeholder="Resolve Conflict" + /> +
+ {choice !== null && ( +
+ The {choice === "guest" ? "account" : "guest"}{" "} + submission will be deleted. +
+ )} +
+ ) : ( +

Name: {event.guest_display_name}

+ )} +
+ ); + })} +
+
+ + {unresolvedConflicts > 0 ? ( +
+ + Please resolve conflicts. +
+ ) : ( + actionText + )} +
+
+ ); +} + +function DataSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function EventDisplay({ + title, + url_code, + conflict, + children, +}: { + title: string; + url_code: string | null; + conflict?: boolean; + children?: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +function EventHeader({ + title, + url_code, + conflict, +}: { + title: string; + url_code: string | null; + conflict?: boolean; +}) { + return ( +
+
+ {conflict && ( + + )} +

{title}

+
+ {url_code && ( + + + View + + + + )} +
+ ); +} + +function ConflictOption({ + label, + name, + selected, +}: { + label: string; + name: string; + selected: boolean | null; +}) { + return ( +
+

{label}

+

{name}

+
+ ); +} diff --git a/frontend/src/app/guest-import/import/page.tsx b/frontend/src/app/guest-import/import/page.tsx new file mode 100644 index 000000000..eba951723 --- /dev/null +++ b/frontend/src/app/guest-import/import/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; + +import ClientPage from "@/app/guest-import/import/page-client"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const guestData = await serverGet(ROUTES.guestImport.getData); + + if ( + guestData.created_events.length === 0 && + guestData.participated_events.length === 0 + ) { + redirect("/settings/guest-import"); + } + + return ; +} diff --git a/frontend/src/app/guest-import/layout.tsx b/frontend/src/app/guest-import/layout.tsx new file mode 100644 index 000000000..f2b1f34de --- /dev/null +++ b/frontend/src/app/guest-import/layout.tsx @@ -0,0 +1,14 @@ +import { Metadata } from "next"; + +import { constructMetadata } from "@/lib/utils/construct-metadata"; + +export function generateMetadata(): Metadata { + return constructMetadata( + "Guest Import", + "Transfer guest data into your account for easy access across devices.", + ); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/frontend/src/app/guest-import/login/page-client.tsx b/frontend/src/app/guest-import/login/page-client.tsx new file mode 100644 index 000000000..692bf032b --- /dev/null +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import Checkbox from "@/components/checkbox"; +import MessagePage from "@/components/layout/message-page"; +import ActionButton from "@/features/button/components/action"; +import GuestSummary from "@/features/guest-import/components/guest-summary"; +import { DONT_SHOW_AGAIN_KEY } from "@/features/guest-import/constants"; +import { GuestDataSummary } from "@/lib/utils/api/types"; + +export default function ClientPage({ + guestSummary, +}: { + guestSummary: GuestDataSummary; +}) { + const [dontShowAgain, setDontShowAgain] = useState(false); + const router = useRouter(); + + const navigate = (route: string) => { + if (dontShowAgain) { + localStorage.setItem(DONT_SHOW_AGAIN_KEY, "true"); + } else { + localStorage.removeItem(DONT_SHOW_AGAIN_KEY); + } + router.push(route); + }; + + return ( +
+ { + navigate("/dashboard"); + }} + />, + { + navigate("/guest-import/import"); + }} + />, + ]} + > +
+
+ +
+
+ + {dontShowAgain && ( +
+ You can import guest data at any time in account settings. +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/guest-import/login/page.tsx b/frontend/src/app/guest-import/login/page.tsx new file mode 100644 index 000000000..28724fb53 --- /dev/null +++ b/frontend/src/app/guest-import/login/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation"; + +import ClientPage from "@/app/guest-import/login/page-client"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const guestSummary = await serverGet(ROUTES.guestImport.getSummary); + const events = guestSummary.created_events; + const availabilities = guestSummary.participated_events; + + if (events === 0 && availabilities === 0) { + redirect("/dashboard"); + } + + return ; +} diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx new file mode 100644 index 000000000..4f0271874 --- /dev/null +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -0,0 +1,39 @@ +import LinkButton from "@/features/button/components/link"; +import GuestSummary from "@/features/guest-import/components/guest-summary"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const guestSummary = await serverGet(ROUTES.guestImport.getSummary); + const events = guestSummary.created_events; + const availabilities = guestSummary.participated_events; + + return ( +
+
+
+

Guest Import

+

+ If you created events or submitted availability while not signed in, + you can transfer that data to your account here. +

+
+ + {events > 0 || availabilities > 0 ? ( + <> + +
+ +
+ + ) : ( +
No guest data found.
+ )} +
+
+ ); +} diff --git a/frontend/src/app/settings/loading.tsx b/frontend/src/app/settings/loading.tsx index 5df2a7350..b14ebb03b 100644 --- a/frontend/src/app/settings/loading.tsx +++ b/frontend/src/app/settings/loading.tsx @@ -13,7 +13,7 @@ export default function Loading() {
-
+
diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx new file mode 100644 index 000000000..97e013d13 --- /dev/null +++ b/frontend/src/components/captcha.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import type { TurnstileInstance } from "@marsidev/react-turnstile"; +import { Turnstile } from "@marsidev/react-turnstile"; +import { useTheme } from "next-themes"; + +import { FloatingDrawer } from "@/features/drawer"; +import { Banner } from "@/features/system-feedback/banner/base"; +import BaseDialog from "@/features/system-feedback/dialog/components/base"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; + +interface CaptchaProps { + onTokenChange: (token: string | null) => void; + backendVerificationFailed: boolean; + onClearBackendError: () => void; + onInitError: () => void; +} + +export default function Captcha({ + onTokenChange, + backendVerificationFailed, + onClearBackendError, + onInitError, +}: CaptchaProps) { + // WIDGET THEMING + const { resolvedTheme } = useTheme(); + const widgetTheme = resolvedTheme === "dark" ? "dark" : "light"; + + // DIALOG TYPE + const isMobile = useCheckMobile(); + const Dialog = isMobile ? FloatingDrawer : BaseDialog; + + const turnstileRef = useRef(null); + const [initError, setInitError] = useState(false); + const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false); + const [recoveryCompleted, setRecoveryCompleted] = useState(false); + + const siteKey = process.env.NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY!; + + // Watch for verification failures from the backend + useEffect(() => { + if (backendVerificationFailed) { + setRecoveryDialogOpen(true); + // Reset the widget to retry in the dialog + turnstileRef.current?.reset(); + } + }, [backendVerificationFailed]); + + const handleSuccess = (token: string) => { + setInitError(false); + onTokenChange(token); + + if (recoveryDialogOpen) { + // If this was in the dialog, clear the backend error + setRecoveryDialogOpen(false); + // This stops the invisible widget from re-rendering and resetting the token + setRecoveryCompleted(true); + onClearBackendError(); + } + }; + + const handleExpire = () => { + onTokenChange(null); + turnstileRef.current?.reset(); + }; + + const handleError = () => { + setInitError(true); + onInitError(); + onTokenChange(null); + }; + + const handleCloseDialog = () => { + setRecoveryDialogOpen(false); + onClearBackendError(); + }; + + // Render banner if the CAPTCHA failed to initialize + if (initError) { + return ( + + Please disable strict ad blockers or try a different browser to + continue. + + ); + } + + // Otherwise, use the invisible widget and only show the dialog on failure + return ( + <> + {!recoveryDialogOpen && !recoveryCompleted && ( +
+ +
+ )} + +
+

+ CAPTCHA failed. Please complete the challenge below and try again. +

+ + +
+
+ + ); +} diff --git a/frontend/src/components/layout/message-page.tsx b/frontend/src/components/layout/message-page.tsx index 969ab9e83..04a35aee0 100644 --- a/frontend/src/components/layout/message-page.tsx +++ b/frontend/src/components/layout/message-page.tsx @@ -3,18 +3,21 @@ import { ButtonArray } from "@/features/button/button-array"; type MessagePageProps = { title: string; description?: string; + children?: React.ReactNode; buttons: ButtonArray; }; export default function MessagePage({ title, description, + children, buttons, }: MessagePageProps) { return ( -
-

{title}

- {description &&

{description}

} +
+

{title}

+ {description &&

{description}

} + {children}
{buttons.map((button, index) => (
{button}
diff --git a/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx b/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx index 63826de10..6994d3b4f 100644 --- a/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx +++ b/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx @@ -8,10 +8,8 @@ import ResetStep from "@/features/account/setting-dialogs/change-password/steps/ import { useChangePasswordFlow } from "@/features/account/setting-dialogs/change-password/use-change-password"; import EmptyButton from "@/features/button/components/empty"; import { FormDialog } from "@/features/system-feedback"; -import useCheckMobile from "@/lib/hooks/use-check-mobile"; export default function ChangePasswordDialog() { - const isMobile = useCheckMobile(); const flow = useChangePasswordFlow(); const [renderedStep, setRenderedStep] = useState(flow.step); @@ -56,7 +54,6 @@ export default function ChangePasswordDialog() { return ( - {SETTINGS_TABS.map((tab) => { - const isActive = pathname === tab.href; - return ( - - {tab.label} - - ); - })} - + <> + + + ); } diff --git a/frontend/src/features/drawer/components/base.tsx b/frontend/src/features/drawer/components/base.tsx index 04d03d6ce..d01070b3a 100644 --- a/frontend/src/features/drawer/components/base.tsx +++ b/frontend/src/features/drawer/components/base.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef, useState } from "react"; import { XIcon } from "lucide-react"; diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx index 80dd0fd38..f12e3adef 100644 --- a/frontend/src/features/event/editor/editor.tsx +++ b/frontend/src/features/event/editor/editor.tsx @@ -5,6 +5,7 @@ import { memo, useState } from "react"; import { TriangleAlertIcon } from "lucide-react"; import { useRouter } from "next/navigation"; +import Captcha from "@/components/captcha"; import MobileFooterIsland from "@/components/mobile-footer-island"; import SegmentedControl from "@/components/segmented-control"; import TextInputField from "@/components/text-input-field"; @@ -60,6 +61,10 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { const { title, customCode, eventRange, timeslots } = state; const router = useRouter(); + // CAPTCHA STATES + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); + const [mobileTab, setMobileTab] = useState("details"); // SUBMIT EVENT INFO @@ -68,15 +73,22 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { try { const validationErrors = await validateEventData(type, state); + if (Object.keys(validationErrors).length > 0) { batchHandleErrors(validationErrors); return false; } + if (type == "new" && !captchaToken) { + handleError("captcha", MESSAGES.ERROR_CAPTCHA_FAILED); + return false; + } + const success = await submitEvent( { title, code: customCode, eventRange, timeslots }, type, eventRange.type, + captchaToken, (code: string) => router.push(`/${code}`), handleError, ); @@ -103,6 +115,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { label={type === "edit" ? "Update Event" : "Create Event"} onClick={submitEventInfo} loadOnSuccess + disabled={captchaInitError} /> ); @@ -115,6 +128,15 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { {errors.rate_limit} )} + {type === "new" && ( + handleError("captcha", "")} + onInitError={() => setCaptchaInitError(true)} + /> + )} +
+
Guest data found on this browser:
+
+ • {events} event + {events !== 1 ? "s" : ""} that you created +
+
+ • {availabilities} availabilit + {availabilities !== 1 ? "ies" : "y"} that you added to{" "} + {availabilities === 1 ? "an event" : "events"} +
+
+ ); +} diff --git a/frontend/src/features/guest-import/constants.ts b/frontend/src/features/guest-import/constants.ts new file mode 100644 index 000000000..6e4c71a07 --- /dev/null +++ b/frontend/src/features/guest-import/constants.ts @@ -0,0 +1 @@ +export const DONT_SHOW_AGAIN_KEY = "login-guest-import-dont-show-again"; diff --git a/frontend/src/features/selector/components/drawer.tsx b/frontend/src/features/selector/components/drawer.tsx index 685f2b8eb..965e92754 100644 --- a/frontend/src/features/selector/components/drawer.tsx +++ b/frontend/src/features/selector/components/drawer.tsx @@ -20,6 +20,7 @@ export default function SelectorDrawer({ drawerNesting = false, trigger, disabled = false, + className, }: DrawerProps) { const [internalOpen, setInternalOpen] = useState(false); @@ -89,6 +90,7 @@ export default function SelectorDrawer({ // Visual styles for disabled state disabled && "bg-foreground/20 text-foreground hover:bg-foreground/20 active:bg-foreground/20 cursor-not-allowed opacity-50 hover:cursor-not-allowed", + className, )} > { if (!isControlled) { @@ -79,6 +88,7 @@ export default function FormDialog({ type="submit" buttonStyle={config.buttonStyle} label={submitLabel} + disabled={submitDisabled} />
diff --git a/frontend/src/features/system-feedback/dialog/props.ts b/frontend/src/features/system-feedback/dialog/props.ts index 08349db8e..5a7fc6519 100644 --- a/frontend/src/features/system-feedback/dialog/props.ts +++ b/frontend/src/features/system-feedback/dialog/props.ts @@ -104,6 +104,9 @@ export type FormDialogProps = CommonDialogProps & { /** Text for the submit button (Defaults to "Save" or "Submit") */ submitLabel?: string; + /** Whether the submit button is disabled */ + submitDisabled?: boolean; + /** Text for the cancel button (Defaults to "Cancel") */ cancelLabel?: string; }; diff --git a/frontend/src/features/system-feedback/toast/context.ts b/frontend/src/features/system-feedback/toast/context.ts index 189b541d4..4f07c6b13 100644 --- a/frontend/src/features/system-feedback/toast/context.ts +++ b/frontend/src/features/system-feedback/toast/context.ts @@ -1,3 +1,5 @@ +"use client"; + import { createContext, useContext } from "react"; import { ToastOptions } from "@/features/system-feedback/toast/type"; diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts index e298bfb1d..15455822c 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -9,6 +9,7 @@ export const MESSAGES = { // generic errors ERROR_GENERIC: "An error occurred. Please try again.", ERROR_RATE_LIMIT: "Too many requests. Please try again later.", + ERROR_CAPTCHA_FAILED: "CAPTCHA verification failed. Please try again.", // auth errors ERROR_EMAIL_MISSING: "Missing email.", diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 72797104b..bda207f2e 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -1,5 +1,6 @@ import { AccountData, + ActiveSessionList, AllAvailability, AuthedPasswordResetCode, AuthedPasswordResetData, @@ -12,6 +13,9 @@ import { EventDetails, EventDisplayNameData, EventEditData, + GuestData, + GuestDataSummary, + GuestImportData, LoginData, MessageResponse, NewEventData, @@ -20,10 +24,9 @@ import { PasswordResetData, RegisterData, SelfAvailability, - VerificationCode, - ActiveSessionList, SessionId, TrueCode, + VerificationCode, } from "@/lib/utils/api/types"; /** @@ -41,6 +44,10 @@ function route(url: string) { } as const; } +type CaptchaToken = { + captcha_token: string; +}; + // Helpers to extract types from the route object export type InferReq = T extends { __req: infer R } ? R : never; export type InferRes = T extends { __res: infer R } ? R : never; @@ -52,7 +59,7 @@ export const ROUTES = { * @method POST * @throws {400} - If the password is not strong enough. */ - register: route("/auth/register/"), + register: route("/auth/register/"), /** * Attempts to resend the verification email for an unverified user account. * @method POST @@ -81,7 +88,7 @@ export const ROUTES = { * Starts the password reset process by sending a password reset email. * @method POST */ - startPasswordReset: route("/auth/start-password-reset/"), + startPasswordReset: route("/auth/start-password-reset/"), /** * Given a valid password reset token, resets the password for a user account. * @method POST @@ -103,14 +110,14 @@ export const ROUTES = { * @throws 400 - If the timeslots are invalid. * @throws 400 - If the custom code is not available or invalid. */ - dateCreate: route("/event/date-create/"), + dateCreate: route("/event/date-create/"), /** * Creates a 'week' type event. * @method POST * @throws 400 - If the timeslots are invalid. * @throws 400 - If the custom code is not available or invalid. */ - weekCreate: route("/event/week-create/"), + weekCreate: route("/event/week-create/"), /** * Checks if a custom event code is available for use. * @method POST @@ -159,7 +166,7 @@ export const ROUTES = { * @throws 400 - If the timeslots are invalid. * @throws 404 - If the event code is invalid. */ - add: route("/availability/add/"), + add: route("/availability/add/"), /** * Checks if a display name is available for the current user in an event. * @method POST @@ -270,4 +277,24 @@ export const ROUTES = { */ deleteAccount: route("/account/delete-account/"), }, + guestImport: { + /** + * Gets a summary of the guest user's created and participated events. + * @method GET + */ + getSummary: route("/guest-import/get-summary/"), + /** + * Gets data about the guest user's created and participated events. + * @method GET + */ + getData: route("/guest-import/get-data/"), + /** + * Imports the guest user's data into the logged-in user's account, resolving any + * availability submission conflicts based on the provided choices. + * @method POST + * @throws 400 - If there is no guest data found. + * @throws 400 - If the availability choices do not match the guest submissions. + */ + importData: route("/guest-import/import-data/"), + } } as const; diff --git a/frontend/src/lib/utils/api/fetch-wrapper.ts b/frontend/src/lib/utils/api/fetch-wrapper.ts index 64c688cca..ab5cb9789 100644 --- a/frontend/src/lib/utils/api/fetch-wrapper.ts +++ b/frontend/src/lib/utils/api/fetch-wrapper.ts @@ -17,6 +17,11 @@ export class ApiErrorResponse extends Error { * If the error is due to rate limiting (status code 429) */ readonly rateLimited: boolean; + /** + * If the error is due to a failed CAPTCHA verification (status code 400 with a specific + * error message) + */ + readonly captchaFailed: boolean; /** * If the error is due to a server error (status code 500-599) */ @@ -32,6 +37,9 @@ export class ApiErrorResponse extends Error { this.data = data; this.badRequest = status >= 400 && status < 500; this.rateLimited = status === 429; + this.captchaFailed = + status === 400 && + (data.error.general?.includes("CAPTCHA verification failed.") ?? false); this.serverError = status >= 500; this.formattedMessage = formatApiError(data); } diff --git a/frontend/src/lib/utils/api/submit-event.ts b/frontend/src/lib/utils/api/submit-event.ts index 71a07128c..4909950c5 100644 --- a/frontend/src/lib/utils/api/submit-event.ts +++ b/frontend/src/lib/utils/api/submit-event.ts @@ -1,5 +1,6 @@ import { EventRange, EventType } from "@/core/event/types"; import { EventEditorType } from "@/features/event/editor/types"; +import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; @@ -13,29 +14,35 @@ export type EventSubmitData = { timeslots: Date[]; }; -type EventSubmitJsonBody = { +type EventJsonBody = { title: string; time_zone: string; timeslots: string[]; +}; + +type NewEventJsonBody = EventJsonBody & { + captcha_token: string; custom_code?: string; - event_code?: string; +}; + +type EditEventJsonBody = EventJsonBody & { + event_code: string; }; export default async function submitEvent( data: EventSubmitData, type: EventEditorType, eventType: EventType, + captchaToken: string | null, onSuccess: (code: string) => void, handleError: (field: string, message: string) => void, ): Promise { let apiRoute; if (eventType === "specific") { - apiRoute = - type === "new" ? ROUTES.event.dateCreate : ROUTES.event.dateEdit; + apiRoute = type === "new" ? ROUTES.event.dateCreate : ROUTES.event.dateEdit; } else { - apiRoute = - type === "new" ? ROUTES.event.weekCreate : ROUTES.event.weekEdit; + apiRoute = type === "new" ? ROUTES.event.weekCreate : ROUTES.event.weekEdit; } if (data.timeslots.length === 0) { @@ -43,7 +50,7 @@ export default async function submitEvent( return false; } - const jsonBody: EventSubmitJsonBody = { + const baseBody: EventJsonBody = { title: data.title, time_zone: data.eventRange.timezone, timeslots: data.timeslots.map((d) => @@ -51,14 +58,17 @@ export default async function submitEvent( ), }; - if (type === "new" && data.code) { - jsonBody.custom_code = data.code; - } else if (type === "edit") { - jsonBody.event_code = data.code; - } + const payload: NewEventJsonBody | EditEventJsonBody = + type === "new" + ? { + ...baseBody, + captcha_token: captchaToken!, + ...(data.code ? { custom_code: data.code } : {}), + } + : { ...baseBody, event_code: data.code }; try { - const resData = await clientPost(apiRoute, jsonBody); + const resData = await clientPost(apiRoute, payload); if (type === "new") { const code = (resData as EventCode).event_code; onSuccess(code); @@ -71,6 +81,8 @@ export default async function submitEvent( const error = e as ApiErrorResponse; if (error.rateLimited) { handleError("rate_limit", error.formattedMessage); + } else if (error.captchaFailed) { + handleError("captcha", MESSAGES.ERROR_CAPTCHA_FAILED); } else { handleError("toast", error.formattedMessage); } diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index e87613620..43e12461f 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -162,3 +162,33 @@ export type AuthedPasswordResetData = { new_password: string; prune_sessions?: boolean; } + +export type GuestDataSummary = { + created_events: number; + participated_events: number; +} + +type GuestCreatedEvent = { + url_code: string | null; + public_id: string; + title: string; +} + +type GuestParticipatedEvent = { + url_code: string | null; + public_id: string; + title: string; + guest_display_name: string; + account_display_name: string | null; +} + +export type GuestData = { + created_events: GuestCreatedEvent[]; + participated_events: GuestParticipatedEvent[]; +} + +export type GuestImportData = { + availability_choices: { + [public_id: string]: "guest" | "account"; + }; +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index c42febd30..c754239d3 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -const protectedRoutes = ["/settings"]; +const protectedRoutes = ["/settings", "/guest-import"]; export function middleware(request: NextRequest) { // Clone the request headers and inject the exact pathname into a custom header.