From 43cb44d6c305c69f527a59eca2445c666af4fd55 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 3 Jul 2026 14:31:07 -0400 Subject: [PATCH 01/91] Create guest data summary endpoint --- backend/api/guest_import/urls.py | 7 ++++ backend/api/guest_import/utils.py | 38 ++++++++++++++++++++ backend/api/guest_import/views.py | 58 +++++++++++++++++++++++++++++++ backend/api/urls.py | 1 + 4 files changed, 104 insertions(+) create mode 100644 backend/api/guest_import/urls.py create mode 100644 backend/api/guest_import/utils.py create mode 100644 backend/api/guest_import/views.py diff --git a/backend/api/guest_import/urls.py b/backend/api/guest_import/urls.py new file mode 100644 index 000000000..c46c1cd1b --- /dev/null +++ b/backend/api/guest_import/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("get-summary/", views.get_summary), +] diff --git a/backend/api/guest_import/utils.py b/backend/api/guest_import/utils.py new file mode 100644 index 000000000..4d49701a4 --- /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) + + set_session_cookie(response, GUEST_COOKIE_NAME, session.session_token, True) + return session.user_account + except UserSession.DoesNotExist: + logger.info("Guest session expired.") + + return None diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py new file mode 100644 index 000000000..12f757a7c --- /dev/null +++ b/backend/api/guest_import/views.py @@ -0,0 +1,58 @@ +import logging + +from django.db.models import Q +from rest_framework import serializers +from rest_framework.response import Response + +from api.decorators import ( + api_endpoint, + require_account_auth, + validate_output, +) +from api.guest_import.utils import get_guest_account +from api.models import ( + EventParticipant, + UserEvent, +) + +logger = logging.getLogger("api") + + +class GuestDataSummarySerializer(serializers.Serializer): + created_events = serializers.IntegerField() + participated_events = serializers.IntegerField() + + +@api_endpoint("GET") +@require_account_auth +@validate_output(GuestDataSummarySerializer) +def get_summary(request): + """ + Returns a count 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 + + created_events = UserEvent.objects.filter( + user_account=guest_user, url_code__isnull=False + ).count() + # Don't include events that the user both created and participated in + participations = EventParticipant.objects.filter( + ~Q(user_event__user_account=guest_user), + user_account=guest_user, + user_event__url_code__isnull=False, + ).count() + + return Response( + {"created_events": created_events, "participated_events": participations}, + status=200, + ) 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")), ] From 5fe22512216b935ed31414afbb1934dfcd4f878b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 3 Jul 2026 14:33:23 -0400 Subject: [PATCH 02/91] Create frontend mapping for guest data summary --- frontend/src/lib/utils/api/endpoints.ts | 8 ++++++++ frontend/src/lib/utils/api/types.ts | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 72797104b..9b79c704e 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -24,6 +24,7 @@ import { ActiveSessionList, SessionId, TrueCode, + GuestDataSummary, } from "@/lib/utils/api/types"; /** @@ -270,4 +271,11 @@ export const ROUTES = { */ deleteAccount: route("/account/delete-account/"), }, + guestImport: { + /** + * Gets a count of events created and participated in by the guest user. + * @method GET + */ + getSummary: route("/guest-import/get-summary/"), + } } as const; diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index e87613620..d476404e9 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -162,3 +162,8 @@ export type AuthedPasswordResetData = { new_password: string; prune_sessions?: boolean; } + +export type GuestDataSummary = { + created_events: number; + participated_events: number; +} From 26428336e29113199028c818127a88c4f2e648e6 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 3 Jul 2026 17:08:13 -0400 Subject: [PATCH 03/91] Remove event creation participation filter --- backend/api/guest_import/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 12f757a7c..7ee8680a9 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -45,9 +45,7 @@ def get_summary(request): created_events = UserEvent.objects.filter( user_account=guest_user, url_code__isnull=False ).count() - # Don't include events that the user both created and participated in participations = EventParticipant.objects.filter( - ~Q(user_event__user_account=guest_user), user_account=guest_user, user_event__url_code__isnull=False, ).count() From 7b3f19a97bc01fd9b8815690d498f7301860931f Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 3 Jul 2026 17:21:46 -0400 Subject: [PATCH 04/91] Create initial guest import page --- .../settings/(submenus)/guest-import/page.tsx | 73 +++++++++++++++++++ .../features/account/settings/sidebar-nav.tsx | 1 + 2 files changed, 74 insertions(+) create mode 100644 frontend/src/app/settings/(submenus)/guest-import/page.tsx 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..a641449ae --- /dev/null +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -0,0 +1,73 @@ +import { TriangleAlertIcon } from "lucide-react"; + +import EmptyButton from "@/features/button/components/empty"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; +import { cn } from "@/lib/utils/classname"; + +export default async function Page() { + let guestSummary: { + created_events: number; + participated_events: number; + } | null = null; + try { + guestSummary = await serverGet(ROUTES.guestImport.getSummary); + } catch { + // Do nothing, keep guestSummary as null to indicate an error occurred + } + + const guestDataFound = + guestSummary !== null && + (guestSummary?.created_events > 0 || guestSummary?.participated_events > 0); + const createdEvents = guestSummary?.created_events ?? 0; + const participatedEvents = guestSummary?.participated_events ?? 0; + + return ( +
+
+
+

Guest Import

+

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

+
+ + {guestDataFound ? ( + <> +
+
Guest data found:
+
+ • {createdEvents} event{createdEvents !== 1 ? "s" : ""} that you + created +
+
+ • {participatedEvents} event + {participatedEvents !== 1 ? "s" : ""} that you added your + availability to +
+
+
+ +
+ + ) : guestSummary === null ? ( +
+ + Something went wrong trying to fetch guest data. Please try again + later. +
+ ) : ( +
No guest data found.
+ )} +
+
+ ); +} diff --git a/frontend/src/features/account/settings/sidebar-nav.tsx b/frontend/src/features/account/settings/sidebar-nav.tsx index 2961dcc2f..6d4a0ae49 100644 --- a/frontend/src/features/account/settings/sidebar-nav.tsx +++ b/frontend/src/features/account/settings/sidebar-nav.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils/classname"; const SETTINGS_TABS = [ { href: "/settings", label: "General" }, + { href: "/settings/guest-import", label: "Guest Import" }, { href: "/settings/security", label: "Security" }, { href: "/settings/remove", label: "Account Removal" }, ] as const; From c5fc9c2b95d9893b69b015e46d3a7376225aba83 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 3 Jul 2026 17:44:28 -0400 Subject: [PATCH 05/91] Change summary endpoint to give full data --- backend/api/guest_import/urls.py | 2 +- backend/api/guest_import/views.py | 68 ++++++++++++++++--- .../settings/(submenus)/guest-import/page.tsx | 36 +++++----- frontend/src/lib/utils/api/endpoints.ts | 6 +- frontend/src/lib/utils/api/types.ts | 18 ++++- 5 files changed, 96 insertions(+), 34 deletions(-) diff --git a/backend/api/guest_import/urls.py b/backend/api/guest_import/urls.py index c46c1cd1b..a0aa690c5 100644 --- a/backend/api/guest_import/urls.py +++ b/backend/api/guest_import/urls.py @@ -3,5 +3,5 @@ from . import views urlpatterns = [ - path("get-summary/", views.get_summary), + path("get-data/", views.get_data), ] diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 7ee8680a9..c836f835e 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -18,39 +18,87 @@ logger = logging.getLogger("api") +class GuestEventSerializer(serializers.Serializer): + url_code = serializers.CharField() + title = serializers.CharField() + + +class GuestParticipationSerializer(serializers.Serializer): + url_code = serializers.CharField() + title = serializers.CharField() + guest_display_name = serializers.CharField() + account_display_name = serializers.CharField(allow_null=True) + + class GuestDataSummarySerializer(serializers.Serializer): - created_events = serializers.IntegerField() - participated_events = serializers.IntegerField() + created_events = serializers.ListField( + child=GuestEventSerializer(), + allow_empty=True, + ) + participated_events = serializers.ListField( + child=GuestParticipationSerializer(), + allow_empty=True, + ) @api_endpoint("GET") @require_account_auth @validate_output(GuestDataSummarySerializer) -def get_summary(request): +def get_data(request): """ - Returns a count of events created and participated in by the guest user. + 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": 0, - "participated_events": 0, + "created_events": [], + "participated_events": [], } response.status_code = 200 return response - created_events = UserEvent.objects.filter( + events = UserEvent.objects.filter( user_account=guest_user, url_code__isnull=False - ).count() + ).select_related("url_code") + created_events = [ + { + "url_code": event.url_code.url_code, + "title": event.title, + } + for event in events + ] + participations = EventParticipant.objects.filter( user_account=guest_user, user_event__url_code__isnull=False, - ).count() + ).select_related("user_event__url_code") + participated_events = [ + { + "url_code": participation.user_event.url_code.url_code, + "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__url_code") + for conflict in conflicts: + for participation in participated_events: + if participation["url_code"] == conflict.user_event.url_code.url_code: + participation["account_display_name"] = conflict.display_name return Response( - {"created_events": created_events, "participated_events": participations}, + { + "created_events": created_events, + "participated_events": participated_events, + }, status=200, ) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index a641449ae..7a6adbaf2 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -3,24 +3,23 @@ import { TriangleAlertIcon } from "lucide-react"; import EmptyButton from "@/features/button/components/empty"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; +import { GuestData } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; export default async function Page() { - let guestSummary: { - created_events: number; - participated_events: number; - } | null = null; + let guestData: GuestData | null = null; try { - guestSummary = await serverGet(ROUTES.guestImport.getSummary); + guestData = await serverGet(ROUTES.guestImport.getData); } catch { - // Do nothing, keep guestSummary as null to indicate an error occurred + // Do nothing, keep guestData as null to indicate an error occurred } const guestDataFound = - guestSummary !== null && - (guestSummary?.created_events > 0 || guestSummary?.participated_events > 0); - const createdEvents = guestSummary?.created_events ?? 0; - const participatedEvents = guestSummary?.participated_events ?? 0; + guestData !== null && + (guestData?.created_events.length > 0 || + guestData?.participated_events.length > 0); + const createdEvents = guestData?.created_events ?? []; + const participatedEvents = guestData?.participated_events ?? []; return (
@@ -38,19 +37,22 @@ export default async function Page() {
Guest data found:
- • {createdEvents} event{createdEvents !== 1 ? "s" : ""} that you - created + • {createdEvents.length} event + {createdEvents.length !== 1 ? "s" : ""} that you created
- • {participatedEvents} event - {participatedEvents !== 1 ? "s" : ""} that you added your + • {participatedEvents.length} event + {participatedEvents.length !== 1 ? "s" : ""} that you added your availability to
@@ -58,7 +60,7 @@ export default async function Page() {
- ) : guestSummary === null ? ( + ) : guestData === null ? (
Something went wrong trying to fetch guest data. Please try again diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 9b79c704e..54c907a6c 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -24,7 +24,7 @@ import { ActiveSessionList, SessionId, TrueCode, - GuestDataSummary, + GuestData, } from "@/lib/utils/api/types"; /** @@ -273,9 +273,9 @@ export const ROUTES = { }, guestImport: { /** - * Gets a count of events created and participated in by the guest user. + * Gets data about the guest user's created and participated events. * @method GET */ - getSummary: route("/guest-import/get-summary/"), + getData: route("/guest-import/get-data/"), } } as const; diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index d476404e9..5fa97e617 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -163,7 +163,19 @@ export type AuthedPasswordResetData = { prune_sessions?: boolean; } -export type GuestDataSummary = { - created_events: number; - participated_events: number; +type GuestCreatedEvent = { + url_code: string; + title: string; +} + +type GuestParticipatedEvent = { + url_code: string; + title: string; + guest_display_name: string; + account_display_name: string | null; +} + +export type GuestData = { + created_events: GuestCreatedEvent[]; + participated_events: GuestParticipatedEvent[]; } From 2893a33889347da0bc811e16b601f65e080e76cb Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Sat, 4 Jul 2026 14:52:28 -0400 Subject: [PATCH 06/91] Add use client to client components For some reason, they were preventing me from using the banner component in a server component. --- frontend/src/features/drawer/components/base.tsx | 2 ++ .../src/features/system-feedback/dialog/components/base.tsx | 2 ++ .../features/system-feedback/dialog/components/confirmation.tsx | 2 ++ .../src/features/system-feedback/dialog/components/form.tsx | 2 ++ frontend/src/features/system-feedback/toast/context.ts | 2 ++ 5 files changed, 10 insertions(+) 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/system-feedback/dialog/components/base.tsx b/frontend/src/features/system-feedback/dialog/components/base.tsx index 8dd0d5d40..9f4e6612d 100644 --- a/frontend/src/features/system-feedback/dialog/components/base.tsx +++ b/frontend/src/features/system-feedback/dialog/components/base.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useCallback, useState } from "react"; import * as Dialog from "@radix-ui/react-dialog"; diff --git a/frontend/src/features/system-feedback/dialog/components/confirmation.tsx b/frontend/src/features/system-feedback/dialog/components/confirmation.tsx index 648a3db0c..c693875fe 100644 --- a/frontend/src/features/system-feedback/dialog/components/confirmation.tsx +++ b/frontend/src/features/system-feedback/dialog/components/confirmation.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useCallback, useState } from "react"; import ActionButton from "@/features/button/components/action"; diff --git a/frontend/src/features/system-feedback/dialog/components/form.tsx b/frontend/src/features/system-feedback/dialog/components/form.tsx index babb8bf53..c7d68ad78 100644 --- a/frontend/src/features/system-feedback/dialog/components/form.tsx +++ b/frontend/src/features/system-feedback/dialog/components/form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useCallback, useState } from "react"; import ActionButton from "@/features/button/components/action"; 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"; From 1f4ce8a7a1ec84e57ed4d5c40889a87ba56d3bba Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Sat, 4 Jul 2026 14:56:47 -0400 Subject: [PATCH 07/91] Add conflict indicator and information --- .../settings/(submenus)/guest-import/page.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index 7a6adbaf2..695d1f647 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,6 +1,7 @@ -import { TriangleAlertIcon } from "lucide-react"; +import { CornerDownRightIcon, TriangleAlertIcon } from "lucide-react"; import EmptyButton from "@/features/button/components/empty"; +import { Banner } from "@/features/system-feedback"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; import { GuestData } from "@/lib/utils/api/types"; @@ -20,6 +21,10 @@ export default async function Page() { guestData?.participated_events.length > 0); const createdEvents = guestData?.created_events ?? []; const participatedEvents = guestData?.participated_events ?? []; + const conflictCount = + guestData?.participated_events.filter( + (event) => event.account_display_name !== null, + ).length ?? 0; return (
@@ -55,7 +60,21 @@ export default async function Page() { {participatedEvents.length !== 1 ? "s" : ""} that you added your availability to
+ {conflictCount > 0 && ( +
+ + {conflictCount} conflict{conflictCount !== 1 ? "s" : ""} found +
+ )}
+ {conflictCount > 0 && ( + + Conflicts occur when you{"'"}ve added availability to the same + event both as a guest and from your account. You can only keep + one when importing, since each user can only have one submission + to an event. + + )}
From df04288ae79a717c1f78c9f686d8c804e587383b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Sat, 4 Jul 2026 14:57:28 -0400 Subject: [PATCH 08/91] Adjust guest data found text --- frontend/src/app/settings/(submenus)/guest-import/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index 695d1f647..ef2988974 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -40,7 +40,9 @@ export default async function Page() { {guestDataFound ? ( <>
-
Guest data found:
+
+ Guest data found on this browser: +
Date: Sat, 4 Jul 2026 15:12:39 -0400 Subject: [PATCH 09/91] Add mobile drawer conversion to FormDialog --- .../src/features/system-feedback/dialog/components/form.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/features/system-feedback/dialog/components/form.tsx b/frontend/src/features/system-feedback/dialog/components/form.tsx index c7d68ad78..03c3dfd9b 100644 --- a/frontend/src/features/system-feedback/dialog/components/form.tsx +++ b/frontend/src/features/system-feedback/dialog/components/form.tsx @@ -7,6 +7,7 @@ import EmptyButton from "@/features/button/components/empty"; import BaseModal from "@/features/system-feedback/dialog/components/base"; import { DIALOG_CONFIG } from "@/features/system-feedback/dialog/config"; import { FormDialogProps } from "@/features/system-feedback/dialog/props"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { cn } from "@/lib/utils/classname"; export default function FormDialog({ @@ -29,6 +30,11 @@ export default function FormDialog({ const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; + const isMobile = useCheckMobile(); + if (isMobile && !asNestedDrawer) { + asNestedDrawer = true; + } + const handleOpenChange = useCallback( (newOpen: boolean) => { if (!isControlled) { From 9cd3eefc8b64b5217ac76a793b988cb7e0f78a52 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Sat, 4 Jul 2026 15:14:17 -0400 Subject: [PATCH 10/91] Remove unnecessary asNestedDrawer props --- .../account/setting-dialogs/change-password/main-dialog.tsx | 3 --- .../src/features/account/setting-dialogs/delete-account.tsx | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) 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 ( Date: Sat, 4 Jul 2026 17:51:59 -0400 Subject: [PATCH 11/91] Add submitDisabled prop to FormDialog --- .../src/features/system-feedback/dialog/components/form.tsx | 2 ++ frontend/src/features/system-feedback/dialog/props.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/frontend/src/features/system-feedback/dialog/components/form.tsx b/frontend/src/features/system-feedback/dialog/components/form.tsx index 03c3dfd9b..5065179a6 100644 --- a/frontend/src/features/system-feedback/dialog/components/form.tsx +++ b/frontend/src/features/system-feedback/dialog/components/form.tsx @@ -16,6 +16,7 @@ export default function FormDialog({ description, onSubmit, submitLabel = "Save", + submitDisabled = false, cancelLabel = "Cancel", children, trigger, @@ -87,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; }; From d38882a82183145e8ab9b51569c78304e09bb13a Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Sun, 5 Jul 2026 21:01:23 -0400 Subject: [PATCH 12/91] Update conflict banner --- .../settings/(submenus)/guest-import/page.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index ef2988974..f1c28c65d 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -65,16 +65,22 @@ export default async function Page() { {conflictCount > 0 && (
- {conflictCount} conflict{conflictCount !== 1 ? "s" : ""} found + {conflictCount} conflict{conflictCount !== 1 ? "s" : ""}
)}
{conflictCount > 0 && ( - + Conflicts occur when you{"'"}ve added availability to the same - event both as a guest and from your account. You can only keep - one when importing, since each user can only have one submission - to an event. + event both as a guest and from your account. You can{" "} + + choose which submission to keep + {" "} + in the next step when resolving each conflict, and the other + will be deleted. )}
From 5e6967ce2396ca3d7fd2ce332773380e203680f5 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Sun, 5 Jul 2026 21:15:06 -0400 Subject: [PATCH 13/91] Create guest import dialog --- .../settings/(submenus)/guest-import/page.tsx | 4 +- .../account/setting-dialogs/guest-import.tsx | 169 ++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 frontend/src/features/account/setting-dialogs/guest-import.tsx diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index f1c28c65d..b98df4e66 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,6 +1,6 @@ import { CornerDownRightIcon, TriangleAlertIcon } from "lucide-react"; -import EmptyButton from "@/features/button/components/empty"; +import GuestImportDialog from "@/features/account/setting-dialogs/guest-import"; import { Banner } from "@/features/system-feedback"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; @@ -84,7 +84,7 @@ export default async function Page() { )}
- +
) : guestData === null ? ( diff --git a/frontend/src/features/account/setting-dialogs/guest-import.tsx b/frontend/src/features/account/setting-dialogs/guest-import.tsx new file mode 100644 index 000000000..b63c35e41 --- /dev/null +++ b/frontend/src/features/account/setting-dialogs/guest-import.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; +import Link from "next/link"; + +import EmptyButton from "@/features/button/components/empty"; +import Selector from "@/features/selector/components/selector"; +import { FormDialog } from "@/features/system-feedback"; +import Tooltip from "@/features/system-feedback/tooltip/base"; +import { GuestData } from "@/lib/utils/api/types"; +import { cn } from "@/lib/utils/classname"; + +export default function GuestImportDialog({ + guestData, +}: { + guestData: GuestData; +}) { + const unresolvedConflicts = guestData.participated_events.some( + (event) => event.account_display_name !== null, + ); + + return ( + } + onSubmit={() => false} + submitLabel="Confirm" + submitDisabled={unresolvedConflicts} + > +
+

Are you sure you want to import this guest data?

+

This action cannot be undone.

+
+ +
+ + {guestData.created_events.map((event) => ( + + ))} + + + {guestData.participated_events.map((event) => { + const conflict = event.account_display_name !== null; + + return ( + + {conflict ? ( +
+
+
+

Guest

+

{event.guest_display_name}

+
+
+

Account

+

{event.account_display_name}

+
+
+
+ {}} + options={[ + { label: "Keep Guest", value: "guest" }, + { + label: "Keep Account", + value: "account", + }, + ]} + value={null} + placeholder="Resolve Conflict" + /> +
+
+ ) : ( +

Name: {event.guest_display_name}

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

{title}

+
+ + + View + + + +
+ ); +} From 53fab724282903694dcdf6a4e825f9938fc72723 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 6 Jul 2026 17:06:14 -0400 Subject: [PATCH 14/91] Add conflict resolution logic --- .../account/setting-dialogs/guest-import.tsx | 112 ++++++++++++++---- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/frontend/src/features/account/setting-dialogs/guest-import.tsx b/frontend/src/features/account/setting-dialogs/guest-import.tsx index b63c35e41..bad1a9267 100644 --- a/frontend/src/features/account/setting-dialogs/guest-import.tsx +++ b/frontend/src/features/account/setting-dialogs/guest-import.tsx @@ -1,6 +1,8 @@ "use client"; -import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { CheckIcon, ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; import Link from "next/link"; import EmptyButton from "@/features/button/components/empty"; @@ -10,15 +12,48 @@ import Tooltip from "@/features/system-feedback/tooltip/base"; import { GuestData } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; +type AvailabilityImportChoice = "guest" | "account"; +type ImportPayload = { + [url_code: string]: AvailabilityImportChoice; +}; + export default function GuestImportDialog({ guestData, }: { guestData: GuestData; }) { - const unresolvedConflicts = guestData.participated_events.some( - (event) => event.account_display_name !== null, + const [importPayload, setImportPayload] = useState<{ + [url_code: string]: AvailabilityImportChoice; + }>( + guestData.participated_events.reduce((acc, event) => { + if (event.account_display_name === null) { + acc[event.url_code] = "guest"; + } + return acc; + }, {} as ImportPayload), ); + const hasUnresolvedConflicts = + 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.url_code), + ); + }, [guestData.participated_events]); + + const resolveConflict = ( + url_code: string, + choice: AvailabilityImportChoice | null, + ) => { + if (choice === null) return; + setImportPayload((prev) => ({ + ...prev, + [url_code]: choice, + })); + }; + return ( } onSubmit={() => false} submitLabel="Confirm" - submitDisabled={unresolvedConflicts} + submitDisabled={hasUnresolvedConflicts} >

Are you sure you want to import this guest data?

@@ -46,26 +81,29 @@ export default function GuestImportDialog({ {guestData.participated_events.map((event) => { - const conflict = event.account_display_name !== null; + const choice = importPayload[event.url_code] ?? null; + const hasConflict = conflictedEvents.has(event.url_code); return ( - {conflict ? ( + {hasConflict ? (
-
-

Guest

-

{event.guest_display_name}

-
-
-

Account

-

{event.account_display_name}

-
+ +
{}} + onChange={(choice) => + resolveConflict(event.url_code, choice) + } options={[ { label: "Keep Guest", value: "guest" }, { @@ -81,10 +121,15 @@ export default function GuestImportDialog({ value: "account", }, ]} - value={null} + value={choice} placeholder="Resolve Conflict" />
+ {choice !== null && ( +
+ The other submission will be deleted. +
+ )}
) : (

Name: {event.guest_display_name}

@@ -94,10 +139,12 @@ export default function GuestImportDialog({ })}
-
- - Please resolve conflicts. -
+ {hasUnresolvedConflicts && ( +
+ + Please resolve conflicts. +
+ )}
); } @@ -167,3 +214,26 @@ function EventHeader({
); } + +function ConflictOption({ + label, + name, + selected, +}: { + label: string; + name: string; + selected: boolean | null; +}) { + return ( +
+

{label}

+

{name}

+
+ ); +} From d0a3a043feb1fbfc85e5f327e41bb3194cc1b8c0 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 6 Jul 2026 17:23:06 -0400 Subject: [PATCH 15/91] Simplify styling for conflict option --- .../src/features/account/setting-dialogs/guest-import.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/account/setting-dialogs/guest-import.tsx b/frontend/src/features/account/setting-dialogs/guest-import.tsx index bad1a9267..b2faddbec 100644 --- a/frontend/src/features/account/setting-dialogs/guest-import.tsx +++ b/frontend/src/features/account/setting-dialogs/guest-import.tsx @@ -227,13 +227,13 @@ function ConflictOption({ return (

{label}

-

{name}

+

{name}

); } From b39b118a4582f3851f1853f1d3083790cd46659c Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 12:57:19 -0400 Subject: [PATCH 16/91] Convert guest import review to page --- .../settings/(submenus)/guest-import/page.tsx | 11 +++++--- .../guest-import/review/page-client.tsx} | 24 +++++------------- .../app/settings/guest-import/review/page.tsx | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+), 22 deletions(-) rename frontend/src/{features/account/setting-dialogs/guest-import.tsx => app/settings/guest-import/review/page-client.tsx} (91%) create mode 100644 frontend/src/app/settings/guest-import/review/page.tsx diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index b98df4e66..b8c8eea2e 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,6 +1,6 @@ import { CornerDownRightIcon, TriangleAlertIcon } from "lucide-react"; -import GuestImportDialog from "@/features/account/setting-dialogs/guest-import"; +import LinkButton from "@/features/button/components/link"; import { Banner } from "@/features/system-feedback"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; @@ -83,9 +83,12 @@ export default async function Page() { will be deleted.
)} -
- -
+ ) : guestData === null ? (
diff --git a/frontend/src/features/account/setting-dialogs/guest-import.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx similarity index 91% rename from frontend/src/features/account/setting-dialogs/guest-import.tsx rename to frontend/src/app/settings/guest-import/review/page-client.tsx index b2faddbec..bcbc8bced 100644 --- a/frontend/src/features/account/setting-dialogs/guest-import.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -2,12 +2,11 @@ import { useMemo, useState } from "react"; -import { CheckIcon, ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; +import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; import Link from "next/link"; -import EmptyButton from "@/features/button/components/empty"; +import HeaderSpacer from "@/features/header/components/header-spacer"; import Selector from "@/features/selector/components/selector"; -import { FormDialog } from "@/features/system-feedback"; import Tooltip from "@/features/system-feedback/tooltip/base"; import { GuestData } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; @@ -17,11 +16,7 @@ type ImportPayload = { [url_code: string]: AvailabilityImportChoice; }; -export default function GuestImportDialog({ - guestData, -}: { - guestData: GuestData; -}) { +export default function ClientPage({ guestData }: { guestData: GuestData }) { const [importPayload, setImportPayload] = useState<{ [url_code: string]: AvailabilityImportChoice; }>( @@ -55,15 +50,8 @@ export default function GuestImportDialog({ }; return ( - } - onSubmit={() => false} - submitLabel="Confirm" - submitDisabled={hasUnresolvedConflicts} - > +
+

Are you sure you want to import this guest data?

This action cannot be undone.

@@ -145,7 +133,7 @@ export default function GuestImportDialog({ Please resolve conflicts.
)} - +
); } diff --git a/frontend/src/app/settings/guest-import/review/page.tsx b/frontend/src/app/settings/guest-import/review/page.tsx new file mode 100644 index 000000000..9a4ce1bf2 --- /dev/null +++ b/frontend/src/app/settings/guest-import/review/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from "next/navigation"; + +import ClientPage from "@/app/settings/guest-import/review/page-client"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; +import { GuestData } from "@/lib/utils/api/types"; + +export default async function Page() { + let guestData: GuestData | null = null; + try { + guestData = await serverGet(ROUTES.guestImport.getData); + } catch { + // Do nothing, keep guestData as null + } + + const guestDataFound = + guestData !== null && + (guestData?.created_events.length > 0 || + guestData?.participated_events.length > 0); + if (!guestDataFound) { + redirect("/settings/guest-import"); + } + + return ; +} From 820bee8c3ae5b56d845151ccaa2f73466b1b1091 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:13:12 -0400 Subject: [PATCH 17/91] Overhaul guest data review layout --- .../guest-import/review/page-client.tsx | 68 ++++++++++++++----- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index bcbc8bced..a2d9e9dd1 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -5,6 +5,8 @@ import { useMemo, useState } from "react"; import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; import Link from "next/link"; +import MobileFooterIsland from "@/components/mobile-footer-island"; +import LinkButton from "@/features/button/components/link"; import HeaderSpacer from "@/features/header/components/header-spacer"; import Selector from "@/features/selector/components/selector"; import Tooltip from "@/features/system-feedback/tooltip/base"; @@ -28,8 +30,8 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { }, {} as ImportPayload), ); - const hasUnresolvedConflicts = - guestData.participated_events.length !== Object.keys(importPayload).length; + const unresolvedConflicts = + guestData.participated_events.length - Object.keys(importPayload).length; const conflictedEvents = useMemo(() => { return new Set( guestData.participated_events @@ -49,15 +51,42 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { })); }; + const cancelButton = ( + + ); + + const importButton = ( + 0} + /> + ); + + const actionText = ( +

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

+ ); + return ( -
+
-
-

Are you sure you want to import this guest data?

-

This action cannot be undone.

+
+

Guest Import Review

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

{actionText}

+
{guestData.created_events.map((event) => (
- {hasUnresolvedConflicts && ( -
- - Please resolve conflicts. -
- )} + + {unresolvedConflicts > 0 ? ( +
+ + Please resolve conflicts. +
+ ) : ( + actionText + )} +
); } @@ -145,7 +181,7 @@ function DataSection({ children: React.ReactNode; }) { return ( -
+
{title}
{children}
@@ -164,7 +200,7 @@ function EventDisplay({ children?: React.ReactNode; }) { return ( -
+
{children}
From 4dc3bc3798c3a7a904d9c0376d86743f2ff1092c Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:14:39 -0400 Subject: [PATCH 18/91] Update submission deletion message --- frontend/src/app/settings/guest-import/review/page-client.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index a2d9e9dd1..7d4530b0a 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -144,7 +144,8 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) {
{choice !== null && (
- The other submission will be deleted. + The {choice === "guest" ? "account" : "guest"}{" "} + submission will be deleted.
)}
From 1d8dde77ffb01e74a5c11a72d48807893b748ba9 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:34:18 -0400 Subject: [PATCH 19/91] Add conflict banner to review page --- .../settings/guest-import/review/page-client.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index 7d4530b0a..88ea9074c 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -9,6 +9,7 @@ import MobileFooterIsland from "@/components/mobile-footer-island"; import LinkButton from "@/features/button/components/link"; import HeaderSpacer from "@/features/header/components/header-spacer"; import Selector from "@/features/selector/components/selector"; +import { Banner } from "@/features/system-feedback"; import Tooltip from "@/features/system-feedback/tooltip/base"; import { GuestData } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; @@ -97,6 +98,18 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { ))} + {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.map((event) => { const choice = importPayload[event.url_code] ?? null; const hasConflict = conflictedEvents.has(event.url_code); From 0d0cf399f578ca2ea813ef953872f6d4b830e609 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:35:57 -0400 Subject: [PATCH 20/91] Create serializers file --- backend/api/guest_import/serializers.py | 24 +++++++++++++++++++++++ backend/api/guest_import/views.py | 26 +------------------------ 2 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 backend/api/guest_import/serializers.py diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py new file mode 100644 index 000000000..635b95690 --- /dev/null +++ b/backend/api/guest_import/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + + +class GuestEventSerializer(serializers.Serializer): + url_code = serializers.CharField() + title = serializers.CharField() + + +class GuestParticipationSerializer(serializers.Serializer): + url_code = serializers.CharField() + title = serializers.CharField() + guest_display_name = serializers.CharField() + account_display_name = serializers.CharField(allow_null=True) + + +class GuestDataSummarySerializer(serializers.Serializer): + created_events = serializers.ListField( + child=GuestEventSerializer(), + allow_empty=True, + ) + participated_events = serializers.ListField( + child=GuestParticipationSerializer(), + allow_empty=True, + ) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index c836f835e..93207eae2 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -1,7 +1,5 @@ import logging -from django.db.models import Q -from rest_framework import serializers from rest_framework.response import Response from api.decorators import ( @@ -9,6 +7,7 @@ require_account_auth, validate_output, ) +from api.guest_import.serializers import GuestDataSummarySerializer from api.guest_import.utils import get_guest_account from api.models import ( EventParticipant, @@ -18,29 +17,6 @@ logger = logging.getLogger("api") -class GuestEventSerializer(serializers.Serializer): - url_code = serializers.CharField() - title = serializers.CharField() - - -class GuestParticipationSerializer(serializers.Serializer): - url_code = serializers.CharField() - title = serializers.CharField() - guest_display_name = serializers.CharField() - account_display_name = serializers.CharField(allow_null=True) - - -class GuestDataSummarySerializer(serializers.Serializer): - created_events = serializers.ListField( - child=GuestEventSerializer(), - allow_empty=True, - ) - participated_events = serializers.ListField( - child=GuestParticipationSerializer(), - allow_empty=True, - ) - - @api_endpoint("GET") @require_account_auth @validate_output(GuestDataSummarySerializer) From 54e1b71e4856efe55e4978fee10d9f49913371df Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:39:15 -0400 Subject: [PATCH 21/91] Fix guest data endpoint response --- backend/api/guest_import/views.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 93207eae2..55c8ee966 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -71,10 +71,10 @@ def get_data(request): if participation["url_code"] == conflict.user_event.url_code.url_code: participation["account_display_name"] = conflict.display_name - return Response( - { - "created_events": created_events, - "participated_events": participated_events, - }, - status=200, - ) + response.data = { + "created_events": created_events, + "participated_events": participated_events, + } + response.status_code = 200 + + return response From ecc296cd2673f1ca996acd41b9c631434961846f Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:42:23 -0400 Subject: [PATCH 22/91] Re-add guest data summary endpoint --- backend/api/guest_import/serializers.py | 7 ++++- backend/api/guest_import/urls.py | 1 + backend/api/guest_import/views.py | 39 ++++++++++++++++++++++++- frontend/src/lib/utils/api/endpoints.ts | 12 ++++++-- frontend/src/lib/utils/api/types.ts | 5 ++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py index 635b95690..e5081d0e7 100644 --- a/backend/api/guest_import/serializers.py +++ b/backend/api/guest_import/serializers.py @@ -1,6 +1,11 @@ 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() title = serializers.CharField() @@ -13,7 +18,7 @@ class GuestParticipationSerializer(serializers.Serializer): account_display_name = serializers.CharField(allow_null=True) -class GuestDataSummarySerializer(serializers.Serializer): +class GuestDataSerializer(serializers.Serializer): created_events = serializers.ListField( child=GuestEventSerializer(), allow_empty=True, diff --git a/backend/api/guest_import/urls.py b/backend/api/guest_import/urls.py index a0aa690c5..b8baa6c50 100644 --- a/backend/api/guest_import/urls.py +++ b/backend/api/guest_import/urls.py @@ -3,5 +3,6 @@ from . import views urlpatterns = [ + path("get-summary/", views.get_summary), path("get-data/", views.get_data), ] diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 55c8ee966..8d94d284a 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -7,7 +7,7 @@ require_account_auth, validate_output, ) -from api.guest_import.serializers import GuestDataSummarySerializer +from api.guest_import.serializers import GuestDataSerializer, GuestDataSummarySerializer from api.guest_import.utils import get_guest_account from api.models import ( EventParticipant, @@ -20,6 +20,43 @@ @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, url_code__isnull=False + ).count() + + participations = EventParticipant.objects.filter( + user_account=guest_user, + user_event__url_code__isnull=False, + ).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. diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 54c907a6c..fc396a24e 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,8 @@ import { EventDetails, EventDisplayNameData, EventEditData, + GuestData, + GuestDataSummary, LoginData, MessageResponse, NewEventData, @@ -20,11 +23,9 @@ import { PasswordResetData, RegisterData, SelfAvailability, - VerificationCode, - ActiveSessionList, SessionId, TrueCode, - GuestData, + VerificationCode, } from "@/lib/utils/api/types"; /** @@ -272,6 +273,11 @@ 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 diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index 5fa97e617..b88a23e74 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -163,6 +163,11 @@ export type AuthedPasswordResetData = { prune_sessions?: boolean; } +export type GuestDataSummary = { + created_events: number; + participated_events: number; +} + type GuestCreatedEvent = { url_code: string; title: string; From 51cb64b8d64ae64ea05174bbd8f8786049776a3b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 16:55:16 -0400 Subject: [PATCH 23/91] Convert settings page to use summary --- .../settings/(submenus)/guest-import/page.tsx | 65 +++++-------------- 1 file changed, 18 insertions(+), 47 deletions(-) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index b8c8eea2e..c64cfb813 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,30 +1,24 @@ -import { CornerDownRightIcon, TriangleAlertIcon } from "lucide-react"; +import { TriangleAlertIcon } from "lucide-react"; import LinkButton from "@/features/button/components/link"; -import { Banner } from "@/features/system-feedback"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; -import { GuestData } from "@/lib/utils/api/types"; +import { GuestDataSummary } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; export default async function Page() { - let guestData: GuestData | null = null; + let guestSummary: GuestDataSummary | null = null; try { - guestData = await serverGet(ROUTES.guestImport.getData); + guestSummary = await serverGet(ROUTES.guestImport.getSummary); } catch { - // Do nothing, keep guestData as null to indicate an error occurred + // Do nothing, keep guestSummary as null to indicate an error occurred } - const guestDataFound = - guestData !== null && - (guestData?.created_events.length > 0 || - guestData?.participated_events.length > 0); - const createdEvents = guestData?.created_events ?? []; - const participatedEvents = guestData?.participated_events ?? []; - const conflictCount = - guestData?.participated_events.filter( - (event) => event.account_display_name !== null, - ).length ?? 0; + const guestSummaryFound = + guestSummary !== null && + (guestSummary?.created_events > 0 || guestSummary?.participated_events > 0); + const createdEvents = guestSummary?.created_events ?? 0; + const participatedEvents = guestSummary?.participated_events ?? 0; return (
@@ -37,52 +31,29 @@ export default async function Page() {

- {guestDataFound ? ( + {guestSummaryFound ? ( <>
Guest data found on this browser:
- • {createdEvents.length} event - {createdEvents.length !== 1 ? "s" : ""} that you created + • {createdEvents} event + {createdEvents !== 1 ? "s" : ""} that you created
- • {participatedEvents.length} event - {participatedEvents.length !== 1 ? "s" : ""} that you added your + • {participatedEvents} event + {participatedEvents !== 1 ? "s" : ""} that you added your availability to
- {conflictCount > 0 && ( -
- - {conflictCount} conflict{conflictCount !== 1 ? "s" : ""} -
- )}
- {conflictCount > 0 && ( - - Conflicts occur when you{"'"}ve added availability to the same - event both as a guest and from your account. You can{" "} - - choose which submission to keep - {" "} - in the next step when resolving each conflict, and the other - will be deleted. - - )} - ) : guestData === null ? ( + ) : guestSummary === null ? (
Something went wrong trying to fetch guest data. Please try again From 1bad28ab3f5da307dfc4e6448a4f81f71ee387fc Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 17:02:33 -0400 Subject: [PATCH 24/91] Simplify error checking --- .../settings/(submenus)/guest-import/page.tsx | 43 +++++-------------- .../app/settings/guest-import/review/page.tsx | 19 +++----- 2 files changed, 16 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index c64cfb813..6a465b21a 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,24 +1,12 @@ -import { TriangleAlertIcon } from "lucide-react"; - import LinkButton from "@/features/button/components/link"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; -import { GuestDataSummary } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; export default async function Page() { - let guestSummary: GuestDataSummary | null = null; - try { - guestSummary = await serverGet(ROUTES.guestImport.getSummary); - } catch { - // Do nothing, keep guestSummary as null to indicate an error occurred - } - - const guestSummaryFound = - guestSummary !== null && - (guestSummary?.created_events > 0 || guestSummary?.participated_events > 0); - const createdEvents = guestSummary?.created_events ?? 0; - const participatedEvents = guestSummary?.participated_events ?? 0; + const guestSummary = await serverGet(ROUTES.guestImport.getSummary); + const events = guestSummary.created_events; + const availabilities = guestSummary.participated_events; return (
@@ -31,26 +19,21 @@ export default async function Page() {

- {guestSummaryFound ? ( + {events > 0 || availabilities > 0 ? ( <>
Guest data found on this browser:
-
- • {createdEvents} event - {createdEvents !== 1 ? "s" : ""} that you created +
+ • {events} event + {events !== 1 ? "s" : ""} that you created
- • {participatedEvents} event - {participatedEvents !== 1 ? "s" : ""} that you added your + • {availabilities} event + {availabilities !== 1 ? "s" : ""} that you added your availability to
@@ -61,12 +44,6 @@ export default async function Page() { className="w-fit" /> - ) : guestSummary === null ? ( -
- - Something went wrong trying to fetch guest data. Please try again - later. -
) : (
No guest data found.
)} diff --git a/frontend/src/app/settings/guest-import/review/page.tsx b/frontend/src/app/settings/guest-import/review/page.tsx index 9a4ce1bf2..f883c50a5 100644 --- a/frontend/src/app/settings/guest-import/review/page.tsx +++ b/frontend/src/app/settings/guest-import/review/page.tsx @@ -3,23 +3,16 @@ import { redirect } from "next/navigation"; import ClientPage from "@/app/settings/guest-import/review/page-client"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; -import { GuestData } from "@/lib/utils/api/types"; export default async function Page() { - let guestData: GuestData | null = null; - try { - guestData = await serverGet(ROUTES.guestImport.getData); - } catch { - // Do nothing, keep guestData as null - } + const guestData = await serverGet(ROUTES.guestImport.getData); - const guestDataFound = - guestData !== null && - (guestData?.created_events.length > 0 || - guestData?.participated_events.length > 0); - if (!guestDataFound) { + if ( + guestData.created_events.length === 0 && + guestData.participated_events.length === 0 + ) { redirect("/settings/guest-import"); } - return ; + return ; } From 8b744e69d8e787bdcb094e0d625e03a0747af5a2 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 17:10:29 -0400 Subject: [PATCH 25/91] Simplify conflict resolution logic --- frontend/src/app/settings/guest-import/review/page-client.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index 88ea9074c..88edaec23 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -43,9 +43,8 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { const resolveConflict = ( url_code: string, - choice: AvailabilityImportChoice | null, + choice: AvailabilityImportChoice, ) => { - if (choice === null) return; setImportPayload((prev) => ({ ...prev, [url_code]: choice, From 69cf59ad3894325a99d9b9608c62696815aa49ed Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 17:13:57 -0400 Subject: [PATCH 26/91] Add none display for import data --- .../src/app/settings/guest-import/review/page-client.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index 88edaec23..eae4d53fc 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -75,6 +75,10 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) {

); + const noneText = ( +
None
+ ); + return (
@@ -88,6 +92,7 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) {

{actionText}

+ {guestData.created_events.length === 0 && noneText} {guestData.created_events.map((event) => ( )} + {guestData.participated_events.length === 0 && noneText} + {guestData.participated_events.map((event) => { const choice = importPayload[event.url_code] ?? null; const hasConflict = conflictedEvents.has(event.url_code); From 136498918e0df44e168f8c470a9bb0d360fb0926 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 7 Jul 2026 17:15:53 -0400 Subject: [PATCH 27/91] Remove drawer nesting on conflict selector --- frontend/src/app/settings/guest-import/review/page-client.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index eae4d53fc..d93cd6d5f 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -146,7 +146,6 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { dialogDescription="Resolve the submission conflict" dialogTitle="Resolve Conflict" id={`resolve-conflict-${event.url_code}`} - drawerNesting={2} onChange={(choice) => resolveConflict(event.url_code, choice) } From 7f4062d45bd38cb1664523c1ceb5ef443b3120af Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 16:49:36 -0400 Subject: [PATCH 28/91] Create import data endpoint --- backend/api/guest_import/serializers.py | 8 ++ backend/api/guest_import/urls.py | 1 + backend/api/guest_import/views.py | 101 +++++++++++++++++++++++- frontend/src/lib/utils/api/endpoints.ts | 7 ++ frontend/src/lib/utils/api/types.ts | 6 ++ 5 files changed, 122 insertions(+), 1 deletion(-) diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py index e5081d0e7..b9863d790 100644 --- a/backend/api/guest_import/serializers.py +++ b/backend/api/guest_import/serializers.py @@ -27,3 +27,11 @@ class GuestDataSerializer(serializers.Serializer): 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 index b8baa6c50..a4b484349 100644 --- a/backend/api/guest_import/urls.py +++ b/backend/api/guest_import/urls.py @@ -5,4 +5,5 @@ 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/views.py b/backend/api/guest_import/views.py index 8d94d284a..2232b1f66 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -1,18 +1,25 @@ import logging +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 +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") @@ -115,3 +122,95 @@ def get_data(request): 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 URL codes 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, url_code__isnull=False + ) + guest_submissions = EventParticipant.objects.filter( + user_account=guest_user + ).select_related("user_event__url_code") + if not guest_events.exists() and not guest_submissions.exists(): + return no_data_found() + + # Check if all the guest user's submissions are accounted for in the choices + if set(availability_choices.keys()) != set( + submission.user_event.url_code.url_code for submission in guest_submissions + ): + 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, url_code__isnull=False + ).update(user_account=account_user) + + # === DELETE DISCARDED SUBMISSIONS === + account_chosen = [] + guest_chosen = [] + for url_code, choice in availability_choices.items(): + match choice: + case "account": + account_chosen.append(url_code) + case "guest": + guest_chosen.append(url_code) + + # Start by removing the guest's submissions from events that have "account" specified + EventParticipant.objects.filter( + user_account=guest_user, user_event__url_code__url_code__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__url_code__url_code__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/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index fc396a24e..c98abac0c 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -15,6 +15,7 @@ import { EventEditData, GuestData, GuestDataSummary, + GuestImportData, LoginData, MessageResponse, NewEventData, @@ -283,5 +284,11 @@ export const ROUTES = { * @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 + */ + importData: route("/guest-import/import-data/"), } } as const; diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index b88a23e74..0ac34d6dc 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -184,3 +184,9 @@ export type GuestData = { created_events: GuestCreatedEvent[]; participated_events: GuestParticipatedEvent[]; } + +export type GuestImportData = { + availability_choices: { + [url_code: string]: "guest" | "account"; + }; +} From c8ee72af384d1bbe51f487328c09cc17c1563f9e Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 16:52:00 -0400 Subject: [PATCH 29/91] Add backend functionality to guest review page --- .../guest-import/review/page-client.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index d93cd6d5f..11fec28d5 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -4,13 +4,17 @@ 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 } from "@/features/system-feedback"; +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"; @@ -20,6 +24,9 @@ type ImportPayload = { }; export default function ClientPage({ guestData }: { guestData: GuestData }) { + const { addToast } = useToast(); + const router = useRouter(); + const [importPayload, setImportPayload] = useState<{ [url_code: string]: AvailabilityImportChoice; }>( @@ -51,6 +58,21 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { })); }; + 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 = ( 0} /> ); From edf832f0d22e58360d682b4e90ec7f385a5319f1 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 16:55:36 -0400 Subject: [PATCH 30/91] Add tooltip to disabled submit button --- .../src/app/settings/guest-import/review/page-client.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/settings/guest-import/review/page-client.tsx index 11fec28d5..6e84aa10e 100644 --- a/frontend/src/app/settings/guest-import/review/page-client.tsx +++ b/frontend/src/app/settings/guest-import/review/page-client.tsx @@ -87,6 +87,11 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { label="Import Data" onClick={submitImport} disabled={unresolvedConflicts > 0} + tooltip={ + unresolvedConflicts > 0 + ? "Please resolve conflicts before importing." + : undefined + } /> ); From 4f6ef02b1941be58d71b5b450c1a6a3b0ef4e48b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 17:21:18 -0400 Subject: [PATCH 31/91] Move guest import to separate route --- .../src/app/{settings => }/guest-import/review/page-client.tsx | 0 frontend/src/app/{settings => }/guest-import/review/page.tsx | 2 +- frontend/src/app/settings/(submenus)/guest-import/page.tsx | 2 +- frontend/src/middleware.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/app/{settings => }/guest-import/review/page-client.tsx (100%) rename frontend/src/app/{settings => }/guest-import/review/page.tsx (86%) diff --git a/frontend/src/app/settings/guest-import/review/page-client.tsx b/frontend/src/app/guest-import/review/page-client.tsx similarity index 100% rename from frontend/src/app/settings/guest-import/review/page-client.tsx rename to frontend/src/app/guest-import/review/page-client.tsx diff --git a/frontend/src/app/settings/guest-import/review/page.tsx b/frontend/src/app/guest-import/review/page.tsx similarity index 86% rename from frontend/src/app/settings/guest-import/review/page.tsx rename to frontend/src/app/guest-import/review/page.tsx index f883c50a5..286675208 100644 --- a/frontend/src/app/settings/guest-import/review/page.tsx +++ b/frontend/src/app/guest-import/review/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; -import ClientPage from "@/app/settings/guest-import/review/page-client"; +import ClientPage from "@/app/guest-import/review/page-client"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index 6a465b21a..a7fb692c3 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -40,7 +40,7 @@ export default async function Page() { 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. From 1e0732d4b96b02dd034ff7a1a8780b947f185bed Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 17:37:24 -0400 Subject: [PATCH 32/91] Add support for children in MessagePage --- frontend/src/components/layout/message-page.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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}
From 866a48e427fdd804b90642d4dcfdd0e30fb9452b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 17:57:44 -0400 Subject: [PATCH 33/91] Move guest data summary to component --- .../settings/(submenus)/guest-import/page.tsx | 19 ++------------- .../guest-import/components/summary.tsx | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 frontend/src/features/guest-import/components/summary.tsx diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index a7fb692c3..eb4c78564 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,7 +1,7 @@ import LinkButton from "@/features/button/components/link"; +import GuestDataSummary from "@/features/guest-import/components/summary"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; -import { cn } from "@/lib/utils/classname"; export default async function Page() { const guestSummary = await serverGet(ROUTES.guestImport.getSummary); @@ -21,22 +21,7 @@ export default async function Page() { {events > 0 || availabilities > 0 ? ( <> -
-
- Guest data found on this browser: -
-
- • {events} event - {events !== 1 ? "s" : ""} that you created -
-
- • {availabilities} event - {availabilities !== 1 ? "s" : ""} that you added your - availability to -
-
+ +
Guest data found on this browser:
+
+ • {events} event + {events !== 1 ? "s" : ""} that you created +
+
+ • {availabilities} event + {availabilities !== 1 ? "s" : ""} that you added your availability to +
+
+ ); +} From 7a457def2e29f6398345bc1f7963fb4db2111bfc Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 17:59:26 -0400 Subject: [PATCH 34/91] Create guest import login page and redirect --- frontend/src/app/(auth)/login/page.tsx | 5 ++- frontend/src/app/guest-import/login/page.tsx | 46 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/guest-import/login/page.tsx diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 0d90179a9..95adb5377 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -24,7 +24,10 @@ export default function Page() { const router = useRouter(); const searchParams = useSearchParams(); - const callbackUrl = getSafeRedirectUrl(searchParams.get("callbackUrl")); + const callbackUrl = getSafeRedirectUrl( + searchParams.get("callbackUrl"), + "/guest-import/login", + ); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); 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..72765607a --- /dev/null +++ b/frontend/src/app/guest-import/login/page.tsx @@ -0,0 +1,46 @@ +import { redirect } from "next/navigation"; + +import MessagePage from "@/components/layout/message-page"; +import LinkButton from "@/features/button/components/link"; +import GuestDataSummary from "@/features/guest-import/components/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; + + if (events === 0 && availabilities === 0) { + redirect("/dashboard"); + } + + return ( +
+ , + , + ]} + > +
+ +
+
+
+ ); +} From 26368272dbbecb161e52adf366b097502f63b86b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 18:12:23 -0400 Subject: [PATCH 35/91] Create client page for login guest import --- .../app/guest-import/login/page-client.tsx | 42 +++++++++++++++++++ frontend/src/app/guest-import/login/page.tsx | 33 +-------------- .../settings/(submenus)/guest-import/page.tsx | 4 +- .../{summary.tsx => guest-summary.tsx} | 2 +- 4 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 frontend/src/app/guest-import/login/page-client.tsx rename frontend/src/features/guest-import/components/{summary.tsx => guest-summary.tsx} (93%) 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..f903bcc9b --- /dev/null +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -0,0 +1,42 @@ +import MessagePage from "@/components/layout/message-page"; +import LinkButton from "@/features/button/components/link"; +import GuestSummary from "@/features/guest-import/components/guest-summary"; +import { GuestDataSummary } from "@/lib/utils/api/types"; + +export default function ClientPage({ + guestSummary, +}: { + guestSummary: GuestDataSummary; +}) { + return ( +
+ , + , + ]} + > +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/guest-import/login/page.tsx b/frontend/src/app/guest-import/login/page.tsx index 72765607a..28724fb53 100644 --- a/frontend/src/app/guest-import/login/page.tsx +++ b/frontend/src/app/guest-import/login/page.tsx @@ -1,8 +1,6 @@ import { redirect } from "next/navigation"; -import MessagePage from "@/components/layout/message-page"; -import LinkButton from "@/features/button/components/link"; -import GuestDataSummary from "@/features/guest-import/components/summary"; +import ClientPage from "@/app/guest-import/login/page-client"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; @@ -15,32 +13,5 @@ export default async function Page() { redirect("/dashboard"); } - return ( -
- , - , - ]} - > -
- -
-
-
- ); + return ; } diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index eb4c78564..cb9ad0eba 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -1,5 +1,5 @@ import LinkButton from "@/features/button/components/link"; -import GuestDataSummary from "@/features/guest-import/components/summary"; +import GuestSummary from "@/features/guest-import/components/guest-summary"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; @@ -21,7 +21,7 @@ export default async function Page() { {events > 0 || availabilities > 0 ? ( <> - + Date: Wed, 8 Jul 2026 18:32:02 -0400 Subject: [PATCH 36/91] Add don't show again toggle and local storage --- .../app/guest-import/login/page-client.tsx | 58 +++++++++++++++---- .../src/features/guest-import/constants.ts | 1 + 2 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 frontend/src/features/guest-import/constants.ts diff --git a/frontend/src/app/guest-import/login/page-client.tsx b/frontend/src/app/guest-import/login/page-client.tsx index f903bcc9b..2c1a611e5 100644 --- a/frontend/src/app/guest-import/login/page-client.tsx +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -1,6 +1,14 @@ +"use client"; + +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import Checkbox from "@/components/checkbox"; import MessagePage from "@/components/layout/message-page"; -import LinkButton from "@/features/button/components/link"; +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({ @@ -8,33 +16,59 @@ export default function ClientPage({ }: { guestSummary: GuestDataSummary; }) { + const [dontShowAgain, setDontShowAgain] = useState(false); + const router = useRouter(); + + const navigate = (route: string) => { + if (dontShowAgain) { + localStorage.setItem(DONT_SHOW_AGAIN_KEY, "true"); + } + router.push(route); + }; + return (
{ + navigate("/dashboard"); + }} />, - { + navigate("/guest-import/review"); + }} />, ]} > -
- +
+
+ +
+
+ + {dontShowAgain && ( +
+ You can import guest data at any time in account settings. +
+ )} +
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"; From cdd7f32d126e110d00f9ecdafb5c0f86aeef3fdc Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 18:35:33 -0400 Subject: [PATCH 37/91] Add don't show again to login redirect --- frontend/src/app/(auth)/login/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 95adb5377..ceedbafaf 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,9 +25,11 @@ export default function Page() { const router = useRouter(); const searchParams = useSearchParams(); + const dontShowGuestImport = + localStorage.getItem(DONT_SHOW_AGAIN_KEY) === "true"; const callbackUrl = getSafeRedirectUrl( searchParams.get("callbackUrl"), - "/guest-import/login", + dontShowGuestImport ? undefined : "/guest-import/login", ); // TOASTS AND ERROR STATES From 686e4bb65b9e292e5714189361a70a9c25ba4f3b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 18:47:31 -0400 Subject: [PATCH 38/91] Create loading page for import review --- .../src/app/guest-import/review/loading.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/src/app/guest-import/review/loading.tsx diff --git a/frontend/src/app/guest-import/review/loading.tsx b/frontend/src/app/guest-import/review/loading.tsx new file mode 100644 index 000000000..98c580f76 --- /dev/null +++ b/frontend/src/app/guest-import/review/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) => ( +
+ ))} +
+
+
+
+
+ ); +} From 2c697f03af46cec6b39e2920bd9cf66156f354ee Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 21:08:19 -0400 Subject: [PATCH 39/91] Adjust stroke width to match bold text --- frontend/src/app/guest-import/review/page-client.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/guest-import/review/page-client.tsx b/frontend/src/app/guest-import/review/page-client.tsx index 6e84aa10e..b7cab5f45 100644 --- a/frontend/src/app/guest-import/review/page-client.tsx +++ b/frontend/src/app/guest-import/review/page-client.tsx @@ -208,7 +208,7 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { > {unresolvedConflicts > 0 ? (
- + Please resolve conflicts.
) : ( @@ -265,7 +265,9 @@ function EventHeader({ return (
- {conflict && } + {conflict && ( + + )}

{title}

From 2bbc633dc7f19c4c1ee264ce83af80e4ccf37ac2 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 21:11:00 -0400 Subject: [PATCH 40/91] Add padding to login import page --- frontend/src/app/guest-import/login/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/guest-import/login/page-client.tsx b/frontend/src/app/guest-import/login/page-client.tsx index 2c1a611e5..cd1dcca40 100644 --- a/frontend/src/app/guest-import/login/page-client.tsx +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -27,7 +27,7 @@ export default function ClientPage({ }; return ( -
+
Date: Wed, 8 Jul 2026 21:12:10 -0400 Subject: [PATCH 41/91] Fix button activation area --- .../app/settings/(submenus)/guest-import/page.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index cb9ad0eba..5c011145d 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -22,12 +22,13 @@ export default async function Page() { {events > 0 || availabilities > 0 ? ( <> - +
+ +
) : (
No guest data found.
From 089189aed5e4d87b795bdac93296f1ab68e5c1a1 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 21:16:20 -0400 Subject: [PATCH 42/91] Add guest-import to reserved keywords --- backend/api/event/utils.py | 1 + 1 file changed, 1 insertion(+) 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", From 9ee37fcd3cc5c30ecd78cd71223a1c327cb4b967 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 21:27:28 -0400 Subject: [PATCH 43/91] Add metadata to guest import pages --- frontend/src/app/guest-import/layout.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 frontend/src/app/guest-import/layout.tsx 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; +} From a9fb9cca90b607045cd98483cf3c3596d7a985c9 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 21:30:02 -0400 Subject: [PATCH 44/91] Rename and reroute guest import review page --- frontend/src/app/guest-import/{review => import}/loading.tsx | 0 .../src/app/guest-import/{review => import}/page-client.tsx | 2 +- frontend/src/app/guest-import/{review => import}/page.tsx | 2 +- frontend/src/app/guest-import/login/page-client.tsx | 2 +- frontend/src/app/settings/(submenus)/guest-import/page.tsx | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename frontend/src/app/guest-import/{review => import}/loading.tsx (100%) rename frontend/src/app/guest-import/{review => import}/page-client.tsx (99%) rename frontend/src/app/guest-import/{review => import}/page.tsx (87%) diff --git a/frontend/src/app/guest-import/review/loading.tsx b/frontend/src/app/guest-import/import/loading.tsx similarity index 100% rename from frontend/src/app/guest-import/review/loading.tsx rename to frontend/src/app/guest-import/import/loading.tsx diff --git a/frontend/src/app/guest-import/review/page-client.tsx b/frontend/src/app/guest-import/import/page-client.tsx similarity index 99% rename from frontend/src/app/guest-import/review/page-client.tsx rename to frontend/src/app/guest-import/import/page-client.tsx index b7cab5f45..d298ae862 100644 --- a/frontend/src/app/guest-import/review/page-client.tsx +++ b/frontend/src/app/guest-import/import/page-client.tsx @@ -110,7 +110,7 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) {
-

Guest Import Review

+

Guest Import

{cancelButton} {importButton} diff --git a/frontend/src/app/guest-import/review/page.tsx b/frontend/src/app/guest-import/import/page.tsx similarity index 87% rename from frontend/src/app/guest-import/review/page.tsx rename to frontend/src/app/guest-import/import/page.tsx index 286675208..eba951723 100644 --- a/frontend/src/app/guest-import/review/page.tsx +++ b/frontend/src/app/guest-import/import/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; -import ClientPage from "@/app/guest-import/review/page-client"; +import ClientPage from "@/app/guest-import/import/page-client"; import { ROUTES } from "@/lib/utils/api/endpoints"; import { serverGet } from "@/lib/utils/api/server-fetch"; diff --git a/frontend/src/app/guest-import/login/page-client.tsx b/frontend/src/app/guest-import/login/page-client.tsx index cd1dcca40..4381bb5a5 100644 --- a/frontend/src/app/guest-import/login/page-client.tsx +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -45,7 +45,7 @@ export default function ClientPage({ buttonStyle="primary" label="Review and Import" onClick={() => { - navigate("/guest-import/review"); + navigate("/guest-import/import"); }} />, ]} diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx index 5c011145d..4f0271874 100644 --- a/frontend/src/app/settings/(submenus)/guest-import/page.tsx +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -26,7 +26,7 @@ export default async function Page() {
From 2c2d5aa4bbd7c9d09989424a483f0e040433b632 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Wed, 8 Jul 2026 21:46:58 -0400 Subject: [PATCH 45/91] Update dashboard guest banner text --- frontend/src/app/dashboard/page-client.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/dashboard/page-client.tsx b/frontend/src/app/dashboard/page-client.tsx index aac3c7afd..56b687024 100644 --- a/frontend/src/app/dashboard/page-client.tsx +++ b/frontend/src/app/dashboard/page-client.tsx @@ -81,7 +81,7 @@ export default function ClientPage({

Dashboard

{!logged_in && ( - +
This data is only available from this browser.{" "} 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 data across devices. You can import this data at any time in + account settings.
)} From 4a708a6b66da6d7ca0725fd8045924dbea636a11 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 13:59:24 -0400 Subject: [PATCH 46/91] Add public ID to UserEvent --- .../migrations/0034_userevent_public_id.py | 20 ++++++++++++++++ .../api/migrations/0035_populate_public_id.py | 23 +++++++++++++++++++ .../0036_apply_unique_constraint.py | 20 ++++++++++++++++ backend/api/models.py | 1 + 4 files changed, 64 insertions(+) create mode 100644 backend/api/migrations/0034_userevent_public_id.py create mode 100644 backend/api/migrations/0035_populate_public_id.py create mode 100644 backend/api/migrations/0036_apply_unique_constraint.py 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" ) From 3f317a0ced68da778ac49d14a6513732e4a9cf31 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 14:05:39 -0400 Subject: [PATCH 47/91] Add UUIDField support to docs --- backend/api/docs/utils.py | 2 ++ 1 file changed, 2 insertions(+) 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" From cb5c955bc45dd79a902e648f8ec707ee7f9fbcdd Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 14:13:31 -0400 Subject: [PATCH 48/91] Add public ID and remove null URL code filter --- backend/api/guest_import/serializers.py | 6 ++-- backend/api/guest_import/views.py | 46 +++++++++++-------------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py index b9863d790..6b6e14deb 100644 --- a/backend/api/guest_import/serializers.py +++ b/backend/api/guest_import/serializers.py @@ -7,12 +7,14 @@ class GuestDataSummarySerializer(serializers.Serializer): class GuestEventSerializer(serializers.Serializer): - url_code = serializers.CharField() + url_code = serializers.CharField(allow_null=True) + public_id = serializers.UUIDField() title = serializers.CharField() class GuestParticipationSerializer(serializers.Serializer): - url_code = serializers.CharField() + 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) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 2232b1f66..cd699c693 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -43,14 +43,9 @@ def get_summary(request): response.status_code = 200 return response - events = UserEvent.objects.filter( - user_account=guest_user, url_code__isnull=False - ).count() + events = UserEvent.objects.filter(user_account=guest_user).count() - participations = EventParticipant.objects.filter( - user_account=guest_user, - user_event__url_code__isnull=False, - ).count() + participations = EventParticipant.objects.filter(user_account=guest_user).count() response.data = { "created_events": events, @@ -81,12 +76,13 @@ def get_data(request): response.status_code = 200 return response - events = UserEvent.objects.filter( - user_account=guest_user, url_code__isnull=False - ).select_related("url_code") + events = UserEvent.objects.filter(user_account=guest_user).select_related( + "url_code" + ) created_events = [ { "url_code": event.url_code.url_code, + "public_id": event.public_id, "title": event.title, } for event in events @@ -94,11 +90,11 @@ def get_data(request): participations = EventParticipant.objects.filter( user_account=guest_user, - user_event__url_code__isnull=False, ).select_related("user_event__url_code") participated_events = [ { "url_code": participation.user_event.url_code.url_code, + "public_id": participation.user_event.public_id, "title": participation.user_event.title, "guest_display_name": participation.display_name, "account_display_name": None, @@ -109,10 +105,10 @@ def get_data(request): conflicts = EventParticipant.objects.filter( user_account=account_user, user_event__in=participations.values_list("user_event", flat=True), - ).select_related("user_event__url_code") + ) for conflict in conflicts: for participation in participated_events: - if participation["url_code"] == conflict.user_event.url_code.url_code: + if participation["public_id"] == conflict.user_event.public_id: participation["account_display_name"] = conflict.display_name response.data = { @@ -158,18 +154,16 @@ def no_data_found(): with transaction.atomic(): # Check if the guest has any data to import - guest_events = UserEvent.objects.filter( - user_account=guest_user, url_code__isnull=False - ) + guest_events = UserEvent.objects.filter(user_account=guest_user) guest_submissions = EventParticipant.objects.filter( user_account=guest_user - ).select_related("user_event__url_code") + ).select_related("user_event") if not guest_events.exists() and not guest_submissions.exists(): return no_data_found() # Check if all the guest user's submissions are accounted for in the choices if set(availability_choices.keys()) != set( - submission.user_event.url_code.url_code for submission in guest_submissions + submission.user_event.public_id for submission in guest_submissions ): response.data = { "error": { @@ -182,27 +176,27 @@ def no_data_found(): return response # Transfer event ownership - UserEvent.objects.filter( - user_account=guest_user, url_code__isnull=False - ).update(user_account=account_user) + UserEvent.objects.filter(user_account=guest_user).update( + user_account=account_user + ) # === DELETE DISCARDED SUBMISSIONS === account_chosen = [] guest_chosen = [] - for url_code, choice in availability_choices.items(): + for public_id, choice in availability_choices.items(): match choice: case "account": - account_chosen.append(url_code) + account_chosen.append(public_id) case "guest": - guest_chosen.append(url_code) + 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__url_code__url_code__in=account_chosen + 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__url_code__url_code__in=guest_chosen + user_account=account_user, user_event__public_id__in=guest_chosen ).delete() # Then of all the remaining guest submissions transfer ownership to the account From 594d3d881854bcf980d1e4dc4e1c3bd209f582c7 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 14:14:36 -0400 Subject: [PATCH 49/91] Add public ID to endpoint type mappings --- frontend/src/lib/utils/api/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index 0ac34d6dc..595bd9b22 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -170,11 +170,13 @@ export type GuestDataSummary = { type GuestCreatedEvent = { url_code: string; + public_id: string; title: string; } type GuestParticipatedEvent = { url_code: string; + public_id: string; title: string; guest_display_name: string; account_display_name: string | null; @@ -187,6 +189,6 @@ export type GuestData = { export type GuestImportData = { availability_choices: { - [url_code: string]: "guest" | "account"; + [public_id: string]: "guest" | "account"; }; } From 13edb89a4be6fa862e27c922335a2b65f4de9ab2 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 14:47:19 -0400 Subject: [PATCH 50/91] Fix UUID comparison --- backend/api/guest_import/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index cd699c693..91bb5edcb 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -163,7 +163,7 @@ def no_data_found(): # Check if all the guest user's submissions are accounted for in the choices if set(availability_choices.keys()) != set( - submission.user_event.public_id for submission in guest_submissions + str(submission.user_event.public_id) for submission in guest_submissions ): response.data = { "error": { From a759555b49f0b633b37ea244ff249fb67211f0a7 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 14:47:26 -0400 Subject: [PATCH 51/91] Add support for public ID and null URL code --- .../app/guest-import/import/page-client.tsx | 56 ++++++++++--------- frontend/src/lib/utils/api/types.ts | 4 +- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/guest-import/import/page-client.tsx b/frontend/src/app/guest-import/import/page-client.tsx index d298ae862..ed0fe9670 100644 --- a/frontend/src/app/guest-import/import/page-client.tsx +++ b/frontend/src/app/guest-import/import/page-client.tsx @@ -20,7 +20,7 @@ import { cn } from "@/lib/utils/classname"; type AvailabilityImportChoice = "guest" | "account"; type ImportPayload = { - [url_code: string]: AvailabilityImportChoice; + [public_id: string]: AvailabilityImportChoice; }; export default function ClientPage({ guestData }: { guestData: GuestData }) { @@ -28,11 +28,11 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { const router = useRouter(); const [importPayload, setImportPayload] = useState<{ - [url_code: string]: AvailabilityImportChoice; + [public_id: string]: AvailabilityImportChoice; }>( guestData.participated_events.reduce((acc, event) => { if (event.account_display_name === null) { - acc[event.url_code] = "guest"; + acc[event.public_id] = "guest"; } return acc; }, {} as ImportPayload), @@ -44,17 +44,17 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { return new Set( guestData.participated_events .filter((event) => event.account_display_name !== null) - .map((event) => event.url_code), + .map((event) => event.public_id), ); }, [guestData.participated_events]); const resolveConflict = ( - url_code: string, + public_id: string, choice: AvailabilityImportChoice, ) => { setImportPayload((prev) => ({ ...prev, - [url_code]: choice, + [public_id]: choice, })); }; @@ -122,7 +122,7 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { {guestData.created_events.length === 0 && noneText} {guestData.created_events.map((event) => ( @@ -144,12 +144,12 @@ export default function ClientPage({ guestData }: { guestData: GuestData }) { {guestData.participated_events.length === 0 && noneText} {guestData.participated_events.map((event) => { - const choice = importPayload[event.url_code] ?? null; - const hasConflict = conflictedEvents.has(event.url_code); + const choice = importPayload[event.public_id] ?? null; + const hasConflict = conflictedEvents.has(event.public_id); return ( - resolveConflict(event.url_code, choice) + resolveConflict(event.public_id, choice) } options={[ { label: "Keep Guest", value: "guest" }, @@ -241,7 +241,7 @@ function EventDisplay({ children, }: { title: string; - url_code: string; + url_code: string | null; conflict?: boolean; children?: React.ReactNode; }) { @@ -259,7 +259,7 @@ function EventHeader({ conflict, }: { title: string; - url_code: string; + url_code: string | null; conflict?: boolean; }) { return ( @@ -270,19 +270,21 @@ function EventHeader({ )}

{title}

- - - View - - - + {url_code && ( + + + View + + + + )}
); } diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index 595bd9b22..43e12461f 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -169,13 +169,13 @@ export type GuestDataSummary = { } type GuestCreatedEvent = { - url_code: string; + url_code: string | null; public_id: string; title: string; } type GuestParticipatedEvent = { - url_code: string; + url_code: string | null; public_id: string; title: string; guest_display_name: string; From c5d21d9283a384aa408b13f1e38bbca770775c0d Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 16:32:41 -0400 Subject: [PATCH 52/91] Add error handling for null URL code --- backend/api/guest_import/views.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 91bb5edcb..4a447e1c7 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -1,5 +1,6 @@ import logging +from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from rest_framework.response import Response @@ -76,12 +77,18 @@ def get_data(request): 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": event.url_code.url_code, + "url_code": get_url_code(event), "public_id": event.public_id, "title": event.title, } @@ -93,7 +100,7 @@ def get_data(request): ).select_related("user_event__url_code") participated_events = [ { - "url_code": participation.user_event.url_code.url_code, + "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, From e27390e38294fab33728d6e99aca80ab7165e987 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 16:40:47 -0400 Subject: [PATCH 53/91] Add logging to guest import endpoints --- backend/api/guest_import/views.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 4a447e1c7..6313f0bb3 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -117,6 +117,11 @@ def get_url_code(event): for participation in participated_events: if participation["public_id"] == conflict.user_event.public_id: 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, @@ -166,12 +171,20 @@ def no_data_found(): 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": [ From e5b781ebe73f012f01e0e45f8a8f920d9a16e8a8 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 17:45:57 -0400 Subject: [PATCH 54/91] Update guest mode banner text --- frontend/src/app/dashboard/page-client.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/dashboard/page-client.tsx b/frontend/src/app/dashboard/page-client.tsx index 56b687024..aabf6106e 100644 --- a/frontend/src/app/dashboard/page-client.tsx +++ b/frontend/src/app/dashboard/page-client.tsx @@ -83,15 +83,15 @@ export default function ClientPage({ {!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. You can import this data at any time in - account settings. + to sync across devices. You can import this data into your account + at any time in account settings.
)} From 35cccd417efc068616e53c5d91c2764fa26f4f88 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 17:49:17 -0400 Subject: [PATCH 55/91] Update guest summary wording --- .../src/features/guest-import/components/guest-summary.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/guest-import/components/guest-summary.tsx b/frontend/src/features/guest-import/components/guest-summary.tsx index f3239db7f..3bc27fd48 100644 --- a/frontend/src/features/guest-import/components/guest-summary.tsx +++ b/frontend/src/features/guest-import/components/guest-summary.tsx @@ -15,8 +15,9 @@ export default function GuestSummary({ {events !== 1 ? "s" : ""} that you created
- • {availabilities} event - {availabilities !== 1 ? "s" : ""} that you added your availability to + • {availabilities} availabilit + {availabilities !== 1 ? "ies" : "y"} that you added to{" "} + {availabilities === 1 ? "an event" : "events"}
); From 1397ca60554eaf0539511e740050d949c6655b7f Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 18:10:44 -0400 Subject: [PATCH 56/91] Add className support to SelectorDrawer --- frontend/src/features/selector/components/drawer.tsx | 2 ++ 1 file changed, 2 insertions(+) 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, )} > Date: Thu, 9 Jul 2026 18:10:48 -0400 Subject: [PATCH 57/91] Change mobile settings nav to dropdown --- .../features/account/settings/sidebar-nav.tsx | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/frontend/src/features/account/settings/sidebar-nav.tsx b/frontend/src/features/account/settings/sidebar-nav.tsx index 6d4a0ae49..723c3de49 100644 --- a/frontend/src/features/account/settings/sidebar-nav.tsx +++ b/frontend/src/features/account/settings/sidebar-nav.tsx @@ -1,8 +1,9 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import Selector from "@/features/selector/components/selector"; import { cn } from "@/lib/utils/classname"; const SETTINGS_TABS = [ @@ -14,26 +15,49 @@ const SETTINGS_TABS = [ export default function SettingsNav() { const pathname = usePathname(); + const router = useRouter(); return ( - + <> + + + ); } From fc11d97397c5254e759620efce58983cf3b4fe8d Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 18:11:51 -0400 Subject: [PATCH 58/91] Update mobile loading skeleton --- frontend/src/app/settings/loading.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() {
-
+
From ae0d96372b64cae65164364b4b7cd69a9bba2547 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:14:59 -0400 Subject: [PATCH 59/91] Update endpoint comment with errors --- frontend/src/lib/utils/api/endpoints.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index c98abac0c..74d53ff0b 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -288,6 +288,8 @@ export const ROUTES = { * 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/"), } From de5ddd4b240a1ca56772df535428f68a6e2614a2 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:17:35 -0400 Subject: [PATCH 60/91] Move session logic outside of try block --- backend/api/guest_import/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/guest_import/utils.py b/backend/api/guest_import/utils.py index 4d49701a4..6ebe44448 100644 --- a/backend/api/guest_import/utils.py +++ b/backend/api/guest_import/utils.py @@ -29,10 +29,10 @@ def get_guest_account(request, response): raise UserSession.DoesNotExist update_session_metadata(request, session) - - set_session_cookie(response, GUEST_COOKIE_NAME, session.session_token, True) - return session.user_account 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 From 1fe1e791d1bea50c0f9f83df8c2e6987163af218 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:19:52 -0400 Subject: [PATCH 61/91] Add select related call --- backend/api/guest_import/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 6313f0bb3..737e30138 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -112,7 +112,7 @@ def get_url_code(event): conflicts = EventParticipant.objects.filter( user_account=account_user, user_event__in=participations.values_list("user_event", flat=True), - ) + ).select_related("user_event") for conflict in conflicts: for participation in participated_events: if participation["public_id"] == conflict.user_event.public_id: From ee9cb7bd119141a34893b7b80ed066713bf37f44 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:22:48 -0400 Subject: [PATCH 62/91] Optimize conflict assignment --- backend/api/guest_import/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 737e30138..18bdd7237 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -113,10 +113,11 @@ def get_url_code(event): 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: - for participation in participated_events: - if participation["public_id"] == conflict.user_event.public_id: - participation["account_display_name"] = conflict.display_name + 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), From 2fc7c979eb60dd9c3f9a90b72330f5a41f17de61 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:24:52 -0400 Subject: [PATCH 63/91] Update docstring for import endpoint --- backend/api/guest_import/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py index 18bdd7237..e852628c9 100644 --- a/backend/api/guest_import/views.py +++ b/backend/api/guest_import/views.py @@ -141,7 +141,7 @@ 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 URL codes to the user's + 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. From e6244b507bc65ecb269093baba18e011b698fe28 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:29:12 -0400 Subject: [PATCH 64/91] Guard local storage read on server --- frontend/src/app/(auth)/login/page.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index ceedbafaf..9674e59e6 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -25,12 +25,7 @@ export default function Page() { const router = useRouter(); const searchParams = useSearchParams(); - const dontShowGuestImport = - localStorage.getItem(DONT_SHOW_AGAIN_KEY) === "true"; - const callbackUrl = getSafeRedirectUrl( - searchParams.get("callbackUrl"), - dontShowGuestImport ? undefined : "/guest-import/login", - ); + const callbackUrl = searchParams.get("callbackUrl"); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -60,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) { From 493d66024365c3ad54eb06e1ef29cf2665fea0dd Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:33:46 -0400 Subject: [PATCH 65/91] Add unchecked don't show again logic --- frontend/src/app/guest-import/login/page-client.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/app/guest-import/login/page-client.tsx b/frontend/src/app/guest-import/login/page-client.tsx index 4381bb5a5..692bf032b 100644 --- a/frontend/src/app/guest-import/login/page-client.tsx +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -22,6 +22,8 @@ export default function ClientPage({ const navigate = (route: string) => { if (dontShowAgain) { localStorage.setItem(DONT_SHOW_AGAIN_KEY, "true"); + } else { + localStorage.removeItem(DONT_SHOW_AGAIN_KEY); } router.push(route); }; From ee47898e13ad5c05b31071653a198a0408ee2a2e Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 9 Jul 2026 21:34:44 -0400 Subject: [PATCH 66/91] Add aria-current to settings nav --- frontend/src/features/account/settings/sidebar-nav.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/account/settings/sidebar-nav.tsx b/frontend/src/features/account/settings/sidebar-nav.tsx index 723c3de49..bcddf11c7 100644 --- a/frontend/src/features/account/settings/sidebar-nav.tsx +++ b/frontend/src/features/account/settings/sidebar-nav.tsx @@ -46,6 +46,7 @@ export default function SettingsNav() { Date: Mon, 13 Jul 2026 17:16:31 -0400 Subject: [PATCH 67/91] Create require_captcha decorator --- backend/.env.example | 4 ++++ backend/api/decorators.py | 47 +++++++++++++++++++++++++++++++++++++++ backend/api/settings.py | 4 ++++ backend/requirements.txt | 4 ++++ 4 files changed, 59 insertions(+) diff --git a/backend/.env.example b/backend/.env.example index bc5e29484..8c2e7899d 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 can be left as a +# placeholder if DEBUG is set to True +CF_TURNSTILE_SECRET_KEY=secret_key + # The URL of the main site, used for generating links (no trailing slash) BASE_URL=https://example.com diff --git a/backend/api/decorators.py b/backend/api/decorators.py index 14d2c5286..23152afbe 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,8 @@ from api.models import UserAccount, UserSession from api.settings import ( ACCOUNT_COOKIE_NAME, + CF_TURNSTILE_SECRET_KEY, + CF_TURNSTILE_VERIFY_URL, GENERIC_ERR_RESPONSE, GUEST_COOKIE_NAME, ThrottleScopes, @@ -19,6 +22,7 @@ RateLimitError, check_rate_limit, delete_session_cookie, + get_client_ip_address, get_metadata, get_session, set_session_cookie, @@ -517,3 +521,46 @@ 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("cf_turnstile_token") + 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 + + CAPTCHA_FAILED_RESPONSE = Response( + {"error": {"general": ["CAPTCHA verification failed."]}}, status=400 + ) + + 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 CAPTCHA_FAILED_RESPONSE + except Exception as e: + logger.warning("CAPTCHA verification failed: %s", e) + return CAPTCHA_FAILED_RESPONSE + + return func(request, *args, **kwargs) + + return wrapper diff --git a/backend/api/settings.py b/backend/api/settings.py index c15ebe4c2..92d80efe9 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -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/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 From ece560485524dc123324ff6ecff42822ca0cd611 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 13 Jul 2026 17:17:54 -0400 Subject: [PATCH 68/91] Add DEBUG bypass for CAPTCHA --- backend/api/decorators.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/api/decorators.py b/backend/api/decorators.py index 23152afbe..f18167693 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -14,6 +14,7 @@ ACCOUNT_COOKIE_NAME, CF_TURNSTILE_SECRET_KEY, CF_TURNSTILE_VERIFY_URL, + DEBUG, GENERIC_ERR_RESPONSE, GUEST_COOKIE_NAME, ThrottleScopes, @@ -532,6 +533,10 @@ def require_captcha(func): @functools.wraps(func) def wrapper(request, *args, **kwargs): + if DEBUG: + logger.debug("Skipping CAPTCHA verification in DEBUG mode.") + return func(request, *args, **kwargs) + client_token = request.data.get("cf_turnstile_token") if not client_token: return Response( From 83e6ad0e6a5e1ac9533619a5a8479c4a92cab59a Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 13 Jul 2026 17:23:29 -0400 Subject: [PATCH 69/91] Add captcha_required field to docs --- backend/api/decorators.py | 1 + backend/api/docs/views.py | 2 ++ backend/api/utils.py | 1 + 3 files changed, 4 insertions(+) diff --git a/backend/api/decorators.py b/backend/api/decorators.py index f18167693..f4a50bb95 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -568,4 +568,5 @@ def wrapper(request, *args, **kwargs): return func(request, *args, **kwargs) + get_metadata(wrapper).captcha_required = True return wrapper 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/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): From 8668a8982919a18fc1ba25247dc52d1ca293cfa4 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 13 Jul 2026 17:23:39 -0400 Subject: [PATCH 70/91] Add UUIDField support to docs --- backend/api/docs/utils.py | 2 ++ 1 file changed, 2 insertions(+) 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" From c24d6f28d6f7d6012071085a63372c573f9c3eef Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 13 Jul 2026 17:47:51 -0400 Subject: [PATCH 71/91] Rename checked CAPTCHA body property --- backend/api/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/decorators.py b/backend/api/decorators.py index f4a50bb95..123f65a23 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -537,7 +537,7 @@ def wrapper(request, *args, **kwargs): logger.debug("Skipping CAPTCHA verification in DEBUG mode.") return func(request, *args, **kwargs) - client_token = request.data.get("cf_turnstile_token") + client_token = request.data.get("captcha_token") if not client_token: return Response( {"error": {"general": ["CAPTCHA token is required."]}}, status=400 From 9398d5638a16924a6ad7a6555f91de6601178304 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 13 Jul 2026 17:57:09 -0400 Subject: [PATCH 72/91] Add CAPTCHA requirement to relevant endpoints --- backend/api/auth/views.py | 4 ++++ backend/api/availability/views.py | 2 ++ backend/api/event/views.py | 3 +++ frontend/src/lib/utils/api/endpoints.ts | 16 ++++++++++------ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index a219c9a28..c092efc3f 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): @@ -119,6 +121,7 @@ def register(request): @api_endpoint("POST") +@require_captcha @validate_json_input(EmailSerializer) @validate_output(MessageOutputSerializer) def resend_register_email(request): @@ -307,6 +310,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/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/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 72797104b..8217537a1 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -41,6 +41,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,12 +56,12 @@ 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 */ - resendRegisterEmail: route("/auth/resend-register-email/"), + resendRegisterEmail: route("/auth/resend-register-email/"), /** * Verifies the email address of an unverified user account. * @method POST @@ -81,7 +85,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 +107,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 +163,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 From cf4b3657045b5f0dad1cb381ea2d618706c9198a Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Mon, 13 Jul 2026 18:36:14 -0400 Subject: [PATCH 73/91] Add turnstile testing keys --- backend/.env.example | 6 +++--- backend/api/decorators.py | 4 ---- frontend/.env.example | 4 ++++ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 8c2e7899d..0049e7b8e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,9 +13,9 @@ AWS_SES_REGION_NAME=region_name AWS_SES_REGION_ENDPOINT=region_endpoint DEFAULT_FROM_EMAIL=email -# Cloudflare Turnstile secret key for CAPTCHA verification. This can be left as a -# placeholder if DEBUG is set to True -CF_TURNSTILE_SECRET_KEY=secret_key +# 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/decorators.py b/backend/api/decorators.py index 123f65a23..5bff81ff3 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -533,10 +533,6 @@ def require_captcha(func): @functools.wraps(func) def wrapper(request, *args, **kwargs): - if DEBUG: - logger.debug("Skipping CAPTCHA verification in DEBUG mode.") - return func(request, *args, **kwargs) - client_token = request.data.get("captcha_token") if not client_token: return Response( 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 From e7d49037e92468ae9ba8d29cc63b40bfd1ad42b3 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 13:05:57 -0400 Subject: [PATCH 74/91] Change error message on backend failure --- backend/api/decorators.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/api/decorators.py b/backend/api/decorators.py index 5bff81ff3..91593e2ee 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -547,20 +547,18 @@ def wrapper(request, *args, **kwargs): if client_ip := get_client_ip_address(request): payload["remoteip"] = client_ip - CAPTCHA_FAILED_RESPONSE = Response( - {"error": {"general": ["CAPTCHA verification failed."]}}, status=400 - ) - 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 CAPTCHA_FAILED_RESPONSE + return Response( + {"error": {"general": ["CAPTCHA verification failed."]}}, status=400 + ) except Exception as e: - logger.warning("CAPTCHA verification failed: %s", e) - return CAPTCHA_FAILED_RESPONSE + logger.warning("CAPTCHA verification had a problem: %s", e) + return GENERIC_ERR_RESPONSE return func(request, *args, **kwargs) From 3d38601ddc5aa441ec10fefedc2e1e67c513295e Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 14:25:38 -0400 Subject: [PATCH 75/91] Create CAPTCHA component --- frontend/package-lock.json | 11 +++ frontend/package.json | 1 + frontend/src/components/captcha.tsx | 117 ++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 frontend/src/components/captcha.tsx 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/components/captcha.tsx b/frontend/src/components/captcha.tsx new file mode 100644 index 000000000..e10fb53eb --- /dev/null +++ b/frontend/src/components/captcha.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import type { TurnstileInstance } from "@marsidev/react-turnstile"; +import { Turnstile } from "@marsidev/react-turnstile"; + +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; +} + +export default function Captcha({ + onTokenChange, + backendVerificationFailed, + onClearBackendError, +}: CaptchaProps) { + // 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 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); + onClearBackendError(); + } + }; + + const handleExpire = () => { + onTokenChange(null); + turnstileRef.current?.reset(); + }; + + const handleError = () => { + setInitError(true); + 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 && ( +
+ +
+ )} + +
+

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

+ + +
+
+ + ); +} From ee97e79d174af53984e6d0334000ebbec42a55f5 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 14:30:26 -0400 Subject: [PATCH 76/91] Add CAPTCHA failed property to ApiErrorData --- frontend/src/lib/utils/api/fetch-wrapper.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/lib/utils/api/fetch-wrapper.ts b/frontend/src/lib/utils/api/fetch-wrapper.ts index 64c688cca..6aa58f404 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."); this.serverError = status >= 500; this.formattedMessage = formatApiError(data); } From e37c8b3f1720f4af63cc16fffb362bf4a7fa85f0 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 14:30:36 -0400 Subject: [PATCH 77/91] Add CAPTCHA to register page --- frontend/src/app/(auth)/register/page.tsx | 34 ++++++++++++++++++----- frontend/src/lib/messages.ts | 1 + 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 79c214c77..e3e3a8b26 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"; @@ -22,6 +23,7 @@ export default function Page() { const [confirmPassword, setConfirmPassword] = useState(""); const [passwordCriteria, setPasswordCriteria] = useState({}); const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); + const [captchaToken, setCaptchaToken] = useState(null); const router = useRouter(); // TOASTS AND ERROR STATES @@ -67,9 +69,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 +87,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 +155,21 @@ export default function Page() { />, ]} > -
- + handleError("captcha", "")} /> +
+ +
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.", From dfff7120b8a55bb2d1b1dcf197ce86742de7aa5c Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 14:43:43 -0400 Subject: [PATCH 78/91] Add text-left to CAPTCHA banner --- frontend/src/components/captcha.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx index e10fb53eb..651efba64 100644 --- a/frontend/src/components/captcha.tsx +++ b/frontend/src/components/captcha.tsx @@ -69,7 +69,7 @@ export default function Captcha({ // Render banner if the CAPTCHA failed to initialize if (initError) { return ( - + Please disable strict ad blockers or try a different browser to continue. From 300d8017a6f48fc809ec758ef049a401b5b17ffd Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 14:47:23 -0400 Subject: [PATCH 79/91] Add debugShowBanner prop --- frontend/src/components/captcha.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx index 651efba64..29acaf320 100644 --- a/frontend/src/components/captcha.tsx +++ b/frontend/src/components/captcha.tsx @@ -14,12 +14,15 @@ interface CaptchaProps { onTokenChange: (token: string | null) => void; backendVerificationFailed: boolean; onClearBackendError: () => void; + // Permanently show the error banner, for testing purposes + debugShowBanner?: boolean; } export default function Captcha({ onTokenChange, backendVerificationFailed, onClearBackendError, + debugShowBanner = false, }: CaptchaProps) { // DIALOG TYPE const isMobile = useCheckMobile(); @@ -67,7 +70,7 @@ export default function Captcha({ }; // Render banner if the CAPTCHA failed to initialize - if (initError) { + if (initError || debugShowBanner) { return ( Please disable strict ad blockers or try a different browser to From 91e53067400851f177eeca1d6ce075cb92863ea6 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 14:51:09 -0400 Subject: [PATCH 80/91] Remove CAPTCHA from resend register email --- backend/api/auth/views.py | 1 - frontend/src/lib/utils/api/endpoints.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index c092efc3f..2f414cb03 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -121,7 +121,6 @@ def register(request): @api_endpoint("POST") -@require_captcha @validate_json_input(EmailSerializer) @validate_output(MessageOutputSerializer) def resend_register_email(request): diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 7087e0e49..bda207f2e 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -64,7 +64,7 @@ export const ROUTES = { * Attempts to resend the verification email for an unverified user account. * @method POST */ - resendRegisterEmail: route("/auth/resend-register-email/"), + resendRegisterEmail: route("/auth/resend-register-email/"), /** * Verifies the email address of an unverified user account. * @method POST From d0e32050172d2861e310f2130ecbf3d70051a000 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 15:04:02 -0400 Subject: [PATCH 81/91] Add CAPTCHA to forgot password --- .../src/app/(auth)/forgot-password/page.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index 4575adf91..79806c9b9 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"; @@ -19,6 +20,7 @@ import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; export default function Page() { const [email, setEmail] = useState(""); const [emailSent, setEmailSent] = useState(false); + const [captchaToken, setCaptchaToken] = useState(null); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -36,15 +38,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 +97,20 @@ export default function Page() { ]} rateLimitError={errors.rate_limit} > -
- + handleError("captcha", "")} /> +
+ +
From 66dab534b2e440114f30fb7785d3fe423b0c1c09 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 16:30:03 -0400 Subject: [PATCH 82/91] Add CAPTCHA to new event editor --- frontend/src/features/event/editor/editor.tsx | 17 +++++++++ frontend/src/lib/utils/api/submit-event.ts | 35 +++++++++++++------ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx index 80dd0fd38..381ee8fe5 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"; @@ -58,6 +59,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { setEndTime, } = useEventContext(); const { title, customCode, eventRange, timeslots } = state; + const [captchaToken, setCaptchaToken] = useState(null); const router = useRouter(); const [mobileTab, setMobileTab] = useState("details"); @@ -68,15 +70,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, ); @@ -115,6 +124,14 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { {errors.rate_limit} )} + {type === "new" && ( + handleError("captcha", "")} + /> + )} +
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 jsonBody: EventJsonBody = { title: data.title, time_zone: data.eventRange.timezone, timeslots: data.timeslots.map((d) => @@ -51,14 +58,18 @@ export default async function submitEvent( ), }; - if (type === "new" && data.code) { - jsonBody.custom_code = data.code; + if (type === "new") { + (jsonBody as NewEventJsonBody).captcha_token = captchaToken!; + if (data.code) (jsonBody as NewEventJsonBody).custom_code = data.code; } else if (type === "edit") { - jsonBody.event_code = data.code; + (jsonBody as EditEventJsonBody).event_code = data.code; } try { - const resData = await clientPost(apiRoute, jsonBody); + const resData = await clientPost( + apiRoute, + jsonBody as NewEventJsonBody | EditEventJsonBody, + ); if (type === "new") { const code = (resData as EventCode).event_code; onSuccess(code); @@ -71,6 +82,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); } From 5c36ebff4679482df3da3b5cfb6da0c2c7fe659d Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 16:41:14 -0400 Subject: [PATCH 83/91] Add CAPTCHA to painting page --- .../[event-code]/painting/page-client.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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..9526b3e5f 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,9 @@ export default function ClientPage({ ); const { displayName, timeZone, userAvailability } = state; + // CAPTCHA STATE + const [captchaToken, setCaptchaToken] = useState(null); + // TOASTS AND ERROR STATES const { addToast } = useToast(); const [errors, setErrors] = useState>({}); @@ -179,6 +183,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 +220,7 @@ export default function ClientPage({ display_name: displayName, availability: payload_availability, time_zone: timeZone, + captcha_token: captchaToken, }; try { @@ -221,6 +234,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); } @@ -258,6 +276,19 @@ export default function ClientPage({ {errors.rate_limit} )} + {/* CAPTCHA + Error */} + + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.captcha; + return newErrors; + }) + } + /> + {/* Header and Button Row */}

{eventName}

From 7549cf2114cab6b2f2b95d8a1af0cdbab70e1cf2 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 16:59:59 -0400 Subject: [PATCH 84/91] Add dynamic widget theming --- frontend/src/components/captcha.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx index 29acaf320..9cbc6cf56 100644 --- a/frontend/src/components/captcha.tsx +++ b/frontend/src/components/captcha.tsx @@ -4,6 +4,7 @@ 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"; @@ -24,6 +25,10 @@ export default function Captcha({ onClearBackendError, debugShowBanner = false, }: CaptchaProps) { + // WIDGET THEMING + const { resolvedTheme } = useTheme(); + const widgetTheme = resolvedTheme === "dark" ? "dark" : "light"; + // DIALOG TYPE const isMobile = useCheckMobile(); const Dialog = isMobile ? FloatingDrawer : BaseDialog; @@ -90,7 +95,7 @@ export default function Captcha({ onSuccess={handleSuccess} onExpire={handleExpire} onError={handleError} - options={{ size: "invisible", theme: "auto" }} + options={{ size: "invisible", theme: widgetTheme }} />
)} @@ -111,7 +116,7 @@ export default function Captcha({ onSuccess={handleSuccess} onExpire={handleExpire} onError={handleError} - options={{ size: "normal", theme: "auto" }} + options={{ size: "normal", theme: widgetTheme }} />
From 6097cad123116f2ab3cac6fb2a17bce01677356e Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 17:03:28 -0400 Subject: [PATCH 85/91] Loosen account creation rate limits --- backend/api/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/api/settings.py b/backend/api/settings.py index 92d80efe9..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", From 546d44940a6504ca0119e4779852f5efeff903a5 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 17:43:01 -0400 Subject: [PATCH 86/91] Add onInitError and submit button disabling --- frontend/src/app/(auth)/forgot-password/page.tsx | 5 +++++ frontend/src/app/(auth)/register/page.tsx | 8 ++++++-- .../src/app/(event)/[event-code]/painting/page-client.tsx | 5 ++++- frontend/src/components/captcha.tsx | 3 +++ frontend/src/features/event/editor/editor.tsx | 7 ++++++- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index 79806c9b9..db01aa51a 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -20,7 +20,10 @@ import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; 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(); @@ -102,6 +105,7 @@ export default function Page() { backendVerificationFailed={!!errors.captcha} onTokenChange={setCaptchaToken} onClearBackendError={() => handleError("captcha", "")} + onInitError={() => setCaptchaInitError(true)} />
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index e3e3a8b26..9fef6ef4b 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -23,9 +23,12 @@ export default function Page() { const [confirmPassword, setConfirmPassword] = useState(""); const [passwordCriteria, setPasswordCriteria] = useState({}); const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); - const [captchaToken, setCaptchaToken] = useState(null); const router = useRouter(); + // CAPTCHA STATES + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); + // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -160,6 +163,7 @@ export default function Page() { backendVerificationFailed={!!errors.captcha} onTokenChange={setCaptchaToken} onClearBackendError={() => 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 9526b3e5f..5774536b0 100644 --- a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx @@ -56,8 +56,9 @@ export default function ClientPage({ ); const { displayName, timeZone, userAvailability } = state; - // CAPTCHA STATE + // CAPTCHA STATES const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); // TOASTS AND ERROR STATES const { addToast } = useToast(); @@ -264,6 +265,7 @@ export default function ClientPage({ } onClick={handleSubmitAvailability} loadOnSuccess + disabled={captchaInitError} /> ); @@ -287,6 +289,7 @@ export default function ClientPage({ return newErrors; }) } + onInitError={() => setCaptchaInitError(true)} /> {/* Header and Button Row */} diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx index 9cbc6cf56..467f6e7b7 100644 --- a/frontend/src/components/captcha.tsx +++ b/frontend/src/components/captcha.tsx @@ -15,6 +15,7 @@ interface CaptchaProps { onTokenChange: (token: string | null) => void; backendVerificationFailed: boolean; onClearBackendError: () => void; + onInitError: () => void; // Permanently show the error banner, for testing purposes debugShowBanner?: boolean; } @@ -23,6 +24,7 @@ export default function Captcha({ onTokenChange, backendVerificationFailed, onClearBackendError, + onInitError, debugShowBanner = false, }: CaptchaProps) { // WIDGET THEMING @@ -66,6 +68,7 @@ export default function Captcha({ const handleError = () => { setInitError(true); + onInitError(); onTokenChange(null); }; diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx index 381ee8fe5..f12e3adef 100644 --- a/frontend/src/features/event/editor/editor.tsx +++ b/frontend/src/features/event/editor/editor.tsx @@ -59,9 +59,12 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { setEndTime, } = useEventContext(); const { title, customCode, eventRange, timeslots } = state; - const [captchaToken, setCaptchaToken] = useState(null); const router = useRouter(); + // CAPTCHA STATES + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaInitError, setCaptchaInitError] = useState(false); + const [mobileTab, setMobileTab] = useState("details"); // SUBMIT EVENT INFO @@ -112,6 +115,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { label={type === "edit" ? "Update Event" : "Create Event"} onClick={submitEventInfo} loadOnSuccess + disabled={captchaInitError} /> ); @@ -129,6 +133,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { backendVerificationFailed={!!errors.captcha} onTokenChange={setCaptchaToken} onClearBackendError={() => handleError("captcha", "")} + onInitError={() => setCaptchaInitError(true)} /> )} From 236d502c46f68511155acd0a35c948090f077842 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 17:54:31 -0400 Subject: [PATCH 87/91] Remove debugShowBanner property --- frontend/src/components/captcha.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx index 467f6e7b7..8c408f707 100644 --- a/frontend/src/components/captcha.tsx +++ b/frontend/src/components/captcha.tsx @@ -16,8 +16,6 @@ interface CaptchaProps { backendVerificationFailed: boolean; onClearBackendError: () => void; onInitError: () => void; - // Permanently show the error banner, for testing purposes - debugShowBanner?: boolean; } export default function Captcha({ @@ -25,7 +23,6 @@ export default function Captcha({ backendVerificationFailed, onClearBackendError, onInitError, - debugShowBanner = false, }: CaptchaProps) { // WIDGET THEMING const { resolvedTheme } = useTheme(); @@ -78,7 +75,7 @@ export default function Captcha({ }; // Render banner if the CAPTCHA failed to initialize - if (initError || debugShowBanner) { + if (initError) { return ( Please disable strict ad blockers or try a different browser to From a8e2ee7e1a341c45ab12f32c774c2ac1cd89bfbe Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 18:30:32 -0400 Subject: [PATCH 88/91] Add non-dict JSON handling --- backend/api/decorators.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/api/decorators.py b/backend/api/decorators.py index 91593e2ee..1091fbf96 100644 --- a/backend/api/decorators.py +++ b/backend/api/decorators.py @@ -533,7 +533,11 @@ def require_captcha(func): @functools.wraps(func) def wrapper(request, *args, **kwargs): - client_token = request.data.get("captcha_token") + 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 From 2b6212f37d2ad984d532c5709a457518f63ecbaf Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 18:55:47 -0400 Subject: [PATCH 89/91] Fix token reset after recovery --- frontend/src/components/captcha.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/captcha.tsx b/frontend/src/components/captcha.tsx index 8c408f707..97e013d13 100644 --- a/frontend/src/components/captcha.tsx +++ b/frontend/src/components/captcha.tsx @@ -35,6 +35,7 @@ export default function Captcha({ 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!; @@ -54,6 +55,8 @@ export default function Captcha({ 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(); } }; @@ -87,7 +90,7 @@ export default function Captcha({ // Otherwise, use the invisible widget and only show the dialog on failure return ( <> - {!recoveryDialogOpen && ( + {!recoveryDialogOpen && !recoveryCompleted && (
Date: Tue, 14 Jul 2026 18:59:09 -0400 Subject: [PATCH 90/91] Add strict boolean casting --- frontend/src/lib/utils/api/fetch-wrapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/api/fetch-wrapper.ts b/frontend/src/lib/utils/api/fetch-wrapper.ts index 6aa58f404..ab5cb9789 100644 --- a/frontend/src/lib/utils/api/fetch-wrapper.ts +++ b/frontend/src/lib/utils/api/fetch-wrapper.ts @@ -39,7 +39,7 @@ export class ApiErrorResponse extends Error { this.rateLimited = status === 429; this.captchaFailed = status === 400 && - data.error.general?.includes("CAPTCHA verification failed."); + (data.error.general?.includes("CAPTCHA verification failed.") ?? false); this.serverError = status >= 500; this.formattedMessage = formatApiError(data); } From 3cd974bcee8c7976098ccbe60b89e2d9be739f95 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Tue, 14 Jul 2026 19:02:53 -0400 Subject: [PATCH 91/91] Implement spread operator for safer type checking --- frontend/src/lib/utils/api/submit-event.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/utils/api/submit-event.ts b/frontend/src/lib/utils/api/submit-event.ts index 7b2b2f5b1..4909950c5 100644 --- a/frontend/src/lib/utils/api/submit-event.ts +++ b/frontend/src/lib/utils/api/submit-event.ts @@ -50,7 +50,7 @@ export default async function submitEvent( return false; } - const jsonBody: EventJsonBody = { + const baseBody: EventJsonBody = { title: data.title, time_zone: data.eventRange.timezone, timeslots: data.timeslots.map((d) => @@ -58,18 +58,17 @@ export default async function submitEvent( ), }; - if (type === "new") { - (jsonBody as NewEventJsonBody).captcha_token = captchaToken!; - if (data.code) (jsonBody as NewEventJsonBody).custom_code = data.code; - } else if (type === "edit") { - (jsonBody as EditEventJsonBody).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 as NewEventJsonBody | EditEventJsonBody, - ); + const resData = await clientPost(apiRoute, payload); if (type === "new") { const code = (resData as EventCode).event_code; onSuccess(code);