diff --git a/apps/passport-client/components/screens/LoginScreens/NewOneClickLoginScreen.tsx b/apps/passport-client/components/screens/LoginScreens/NewOneClickLoginScreen.tsx new file mode 100644 index 0000000000..5edb06a7e8 --- /dev/null +++ b/apps/passport-client/components/screens/LoginScreens/NewOneClickLoginScreen.tsx @@ -0,0 +1,169 @@ +import { requestGenericIssuanceTicketPreviews } from "@pcd/passport-interface"; +import { Button, Spacer } from "@pcd/passport-ui"; +import { IPODTicketData } from "@pcd/pod-ticket-pcd"; +import { PODTicketCardBodyImpl } from "@pcd/pod-ticket-pcd-ui"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { appConfig } from "../../../src/appConfig"; +import { useDispatch, useSelf } from "../../../src/appHooks"; +import { MaybeModal } from "../../modals/Modal"; +import { AppContainer } from "../../shared/AppContainer"; +import { + CardBodyContainer, + CardHeader, + CardOutlineExpanded +} from "../../shared/PCDCard"; +import { ScreenLoader } from "../../shared/ScreenLoader"; + +/** + * format: http://localhost:3000/#/one-click-login/:email/:code/:targetFolder/:pipelineId?/:serverUrl? + * - `code` is the pretix or lemonade order code + * - `email` is the email address of the ticket to whom the ticket was issued + * - `targetFolder` is the folder to redirect to after login. optional. + * example: http://localhost:3000/#/one-click-login/ivan@0xparc.org/123456/0xPARC%2520Summer%2520'24 + */ +export function NewOneClickLoginScreen(): JSX.Element | null { + const self = useSelf(); + const dispatch = useDispatch(); + const { email, code, targetFolder, pipelineId, serverUrl } = useParams(); + const [ticketPreviews, setTicketPreviews] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + const redirectToTargetFolder = useCallback(() => { + if (targetFolder) { + window.location.hash = `#/?folder=${encodeURIComponent(targetFolder)}`; + } else { + window.location.hash = "#/"; + } + }, [targetFolder]); + + const handleLoadTicketPreviews = useCallback(async () => { + if (!email || !code) { + return; + } + try { + const previewRes = await requestGenericIssuanceTicketPreviews( + serverUrl ?? appConfig.zupassServer, + email, + code, + pipelineId + ); + + setLoading(false); + if (previewRes.success) { + setTicketPreviews(previewRes.value.tickets); + } else { + setError(previewRes.error); + } + } catch (err) { + await dispatch({ + type: "error", + error: { + title: "An error occured", + message: (err as Error).message || "An error occured" + } + }); + } + }, [email, code, serverUrl, pipelineId, dispatch]); + + const handleOneClickLogin = useCallback(async () => { + if (!email || !code) { + return; + } + + try { + setLoading(true); + await dispatch({ + type: "one-click-login", + email, + code, + targetFolder + }); + } catch (err) { + setLoading(false); + await dispatch({ + type: "error", + error: { + title: "An error occured", + message: (err as Error).message || "An error occured" + } + }); + } + }, [email, code, dispatch, targetFolder]); + + useEffect(() => { + if (process.env.ONE_CLICK_LOGIN_ENABLED !== "true") { + window.location.hash = "#/"; + return; + } + + if (self) { + if (!self.emails?.includes(email as string)) { + alert( + `You are already logged in as ${ + self.emails.length === 1 + ? self.emails?.[0] + : "an account that owns the following email addresses: " + + self.emails.join(", ") + }. Please log out and try navigating to the link again.` + ); + window.location.hash = "#/"; + } else { + redirectToTargetFolder(); + } + } else if (!email || !code) { + window.location.hash = "#/"; + } else { + handleLoadTicketPreviews(); + } + }, [ + self, + targetFolder, + handleLoadTicketPreviews, + redirectToTargetFolder, + email, + code + ]); + + return ( + <> + + + {loading && } + + {error &&
{error}
} + + {!loading && ( + <> + + + {ticketPreviews.length === 0 ? ( +
No tickets found
+ ) : ( + <> + {ticketPreviews.map((ticket, i) => ( + + + + {ticket.eventName} ({ticket.ticketName}) + + + + + ))} + + + )} + + + + )} +
+ + ); +} diff --git a/apps/passport-client/pages/index.tsx b/apps/passport-client/pages/index.tsx index 1305f693c5..a573a04af9 100644 --- a/apps/passport-client/pages/index.tsx +++ b/apps/passport-client/pages/index.tsx @@ -34,6 +34,7 @@ import { AlreadyRegisteredScreen } from "../components/screens/LoginScreens/Alre import { CreatePasswordScreen } from "../components/screens/LoginScreens/CreatePasswordScreen"; import { LoginInterstitialScreen } from "../components/screens/LoginScreens/LoginInterstitialScreen"; import { LoginScreen } from "../components/screens/LoginScreens/LoginScreen"; +import { NewOneClickLoginScreen } from "../components/screens/LoginScreens/NewOneClickLoginScreen"; import { NewPassportScreen } from "../components/screens/LoginScreens/NewPassportScreen"; import { OneClickLoginScreen } from "../components/screens/LoginScreens/OneClickLoginScreen"; import { PrivacyNoticeScreen } from "../components/screens/LoginScreens/PrivacyNoticeScreen"; @@ -150,6 +151,10 @@ function RouterImpl(): JSX.Element { path="one-click-login/:email/:code/:targetFolder" element={} /> + } + /> } diff --git a/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts b/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts index cbd71cae01..c78e267904 100644 --- a/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts +++ b/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts @@ -21,7 +21,8 @@ import { PodboxTicketActionRequest, PodboxTicketActionResponseValue, PollFeedRequest, - PollFeedResponseValue + PollFeedResponseValue, + TicketPreviewResultValue } from "@pcd/passport-interface"; import { SerializedSemaphoreGroup } from "@pcd/semaphore-group-pcd"; import { sleep } from "@pcd/util"; @@ -667,4 +668,23 @@ export function initGenericIssuanceRoutes( res.json(result satisfies GenericIssuanceSendPipelineEmailResponseValue); } ); + + app.get( + "/generic-issuance/api/ticket-previews/:email/:orderCode/:pipelineId?", + async (req, res) => { + checkGenericIssuanceServiceStarted(genericIssuanceService); + + const email = checkUrlParam(req, "email"); + const orderCode = checkUrlParam(req, "orderCode"); + const pipelineId = req.params.pipelineId; + + const result = await genericIssuanceService.handleGetTicketPreview( + email, + orderCode, + pipelineId + ); + + res.json(result satisfies TicketPreviewResultValue); + } + ); } diff --git a/apps/passport-server/src/services/generic-issuance/GenericIssuanceService.ts b/apps/passport-server/src/services/generic-issuance/GenericIssuanceService.ts index e635054fa3..17061dd93c 100644 --- a/apps/passport-server/src/services/generic-issuance/GenericIssuanceService.ts +++ b/apps/passport-server/src/services/generic-issuance/GenericIssuanceService.ts @@ -20,7 +20,8 @@ import { PodboxTicketActionRequest, PodboxTicketActionResponseValue, PollFeedRequest, - PollFeedResponseValue + PollFeedResponseValue, + TicketPreviewResultValue } from "@pcd/passport-interface"; import { RollbarService } from "@pcd/server-shared"; import { Request } from "express"; @@ -55,6 +56,7 @@ import { IBadgeGiftingDB, IContactSharingDB } from "../../database/queries/ticketActionDBs"; +import { PCDHTTPError } from "../../routing/pcdHttpError"; import { ApplicationContext } from "../../types"; import { logger } from "../../util/logger"; import { DiscordService } from "../discordService"; @@ -62,6 +64,7 @@ import { EmailService } from "../emailService"; import { PagerDutyService } from "../pagerDutyService"; import { PersistentCacheService } from "../persistentCacheService"; import { InMemoryPipelineAtomDB } from "./InMemoryPipelineAtomDB"; +import { PretixPipeline } from "./pipelines/PretixPipeline"; import { Pipeline, PipelineUser } from "./pipelines/types"; import { CredentialSubservice } from "./subservices/CredentialSubservice"; import { PipelineSubservice } from "./subservices/PipelineSubservice"; @@ -345,4 +348,43 @@ export class GenericIssuanceService { public async getEdgeCityBalances(): Promise { return getEdgeCityBalances(this.context.dbPool); } + + /** + * Given an email and order code, and optionally a pipeline ID (which defaults to DEVCON_PIPELINE_ID), + * returns the ticket previews for the given email and order code. A ticket preview is basically a + * PODTicket in non-pcd form - just the raw IPODTicketData. This is used to display all the tickets + * a user might need when trying to check into an event, without having them go through a costly + * and slow account registration flow. + */ + public async handleGetTicketPreview( + email: string, + orderCode: string, + pipelineId?: string + ): Promise { + const requestedPipelineId = pipelineId ?? process.env.DEVCON_PIPELINE_ID; + const pipeline = (await this.getAllPipelineInstances()).find( + (p) => p.id === requestedPipelineId && PretixPipeline.is(p) + ) as PretixPipeline | undefined; + + if (!pipeline) { + throw new PCDHTTPError( + 400, + "handleGetTicketPreview: pipeline not found " + requestedPipelineId + ); + } + + const tickets = await pipeline.getAllTickets(); + + const matchingTickets = tickets.atoms.filter( + (atom) => atom.email === email && atom.orderCode === orderCode + ); + + const ticketDatas = matchingTickets.map( + (atom) => pipeline.atomToPODTicketData(atom, "1") // fake semaphore id as it's not needed for the ticket preview + ); + + return { + tickets: ticketDatas + } satisfies TicketPreviewResultValue; + } } diff --git a/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts b/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts index 679ab98954..3c196393bf 100644 --- a/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts +++ b/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts @@ -1119,7 +1119,7 @@ export class PretixPipeline implements BasePipeline { }); } - private atomToPODTicketData( + public atomToPODTicketData( atom: PretixAtom, semaphoreV4Id: string ): IPODTicketData { diff --git a/packages/lib/passport-interface/package.json b/packages/lib/passport-interface/package.json index c937143f4c..f8d4aaf69e 100644 --- a/packages/lib/passport-interface/package.json +++ b/packages/lib/passport-interface/package.json @@ -66,6 +66,7 @@ "@pcd/pcd-collection": "0.11.6", "@pcd/pcd-types": "0.11.4", "@pcd/pod": "0.1.7", + "@pcd/pod-ticket-pcd": "0.1.7", "@pcd/semaphore-group-pcd": "0.11.6", "@pcd/semaphore-identity-pcd": "0.11.6", "@pcd/pod-pcd": "0.1.7", diff --git a/packages/lib/passport-interface/src/RequestTypes.ts b/packages/lib/passport-interface/src/RequestTypes.ts index 6f715fe361..44f255ec5d 100644 --- a/packages/lib/passport-interface/src/RequestTypes.ts +++ b/packages/lib/passport-interface/src/RequestTypes.ts @@ -2,6 +2,7 @@ import { EdDSAPublicKey } from "@pcd/eddsa-pcd"; import { EdDSATicketPCD, EdDSATicketPCDTypeName } from "@pcd/eddsa-ticket-pcd"; import { PCDAction } from "@pcd/pcd-collection"; import { ArgsOf, PCDOf, PCDPackage, SerializedPCD } from "@pcd/pcd-types"; +import { IPODTicketData } from "@pcd/pod-ticket-pcd/src/schema"; import { SerializedSemaphoreGroup } from "@pcd/semaphore-group-pcd"; import { SemaphoreSignaturePCD } from "@pcd/semaphore-signature-pcd"; import { Credential } from "./Credential"; @@ -1236,3 +1237,7 @@ export type OneClickEmailResponseValue = { */ values: Record; }; + +export type TicketPreviewResultValue = { + tickets: Array; +}; diff --git a/packages/lib/passport-interface/src/api/requestGenericIssuanceTicketPreviews.ts b/packages/lib/passport-interface/src/api/requestGenericIssuanceTicketPreviews.ts new file mode 100644 index 0000000000..9538575f6c --- /dev/null +++ b/packages/lib/passport-interface/src/api/requestGenericIssuanceTicketPreviews.ts @@ -0,0 +1,31 @@ +import urlJoin from "url-join"; +import { TicketPreviewResultValue } from "../RequestTypes"; +import { APIResult } from "./apiResult"; +import { httpGetSimple } from "./makeRequest"; + +/** + * Asks the server to fetch the ticket previews for the given email and order code. + */ +export async function requestGenericIssuanceTicketPreviews( + zupassServerUrl: string, + email: string, + orderCode: string, + pipelineId?: string +): Promise { + return httpGetSimple( + urlJoin( + zupassServerUrl, + `/generic-issuance/api/ticket-previews`, + encodeURIComponent(email), + encodeURIComponent(orderCode) + ) + (pipelineId ? `/${pipelineId}` : ""), + async (resText) => ({ + value: JSON.parse(resText) as TicketPreviewResultValue, + success: true + }), + {} + ); +} + +export type GenericIssuanceTicketPreviewResponse = + APIResult; diff --git a/packages/lib/passport-interface/src/index.ts b/packages/lib/passport-interface/src/index.ts index 0577c23aab..d398158b97 100644 --- a/packages/lib/passport-interface/src/index.ts +++ b/packages/lib/passport-interface/src/index.ts @@ -29,6 +29,7 @@ export * from "./api/requestGenericIssuanceSemaphoreGroup"; export * from "./api/requestGenericIssuanceSemaphoreGroupRoot"; export * from "./api/requestGenericIssuanceSendPipelineEmail"; export * from "./api/requestGenericIssuanceSetManualCheckInState"; +export * from "./api/requestGenericIssuanceTicketPreviews"; export * from "./api/requestGenericIssuanceUpsertPipeline"; export * from "./api/requestGenericIssuanceValidSemaphoreGroup"; export * from "./api/requestIssuanceServiceEnabled"; diff --git a/packages/lib/passport-interface/tsconfig.cjs.json b/packages/lib/passport-interface/tsconfig.cjs.json index 0255a9fe02..3d965fdcdb 100644 --- a/packages/lib/passport-interface/tsconfig.cjs.json +++ b/packages/lib/passport-interface/tsconfig.cjs.json @@ -34,6 +34,9 @@ { "path": "../pod/tsconfig.cjs.json" }, + { + "path": "../../pcd/pod-ticket-pcd/tsconfig.cjs.json" + }, { "path": "../../pcd/semaphore-group-pcd/tsconfig.cjs.json" }, diff --git a/packages/lib/passport-interface/tsconfig.esm.json b/packages/lib/passport-interface/tsconfig.esm.json index c838f678b0..72771345f7 100644 --- a/packages/lib/passport-interface/tsconfig.esm.json +++ b/packages/lib/passport-interface/tsconfig.esm.json @@ -34,6 +34,9 @@ { "path": "../pod/tsconfig.esm.json" }, + { + "path": "../../pcd/pod-ticket-pcd/tsconfig.esm.json" + }, { "path": "../../pcd/semaphore-group-pcd/tsconfig.esm.json" }, diff --git a/packages/lib/passport-interface/tsconfig.json b/packages/lib/passport-interface/tsconfig.json index 08c2b85aed..77ff43b7c2 100644 --- a/packages/lib/passport-interface/tsconfig.json +++ b/packages/lib/passport-interface/tsconfig.json @@ -45,6 +45,9 @@ { "path": "../pod" }, + { + "path": "../../pcd/pod-ticket-pcd" + }, { "path": "../../pcd/semaphore-group-pcd" }, diff --git a/packages/pcd/pod-ticket-pcd/src/index.ts b/packages/pcd/pod-ticket-pcd/src/index.ts index 7deb279628..a6fb3ef1f3 100644 --- a/packages/pcd/pod-ticket-pcd/src/index.ts +++ b/packages/pcd/pod-ticket-pcd/src/index.ts @@ -1,2 +1,3 @@ export * from "./PODTicketPCD"; export * from "./PODTicketPCDPackage"; +export * from "./schema"; diff --git a/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx b/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx index d904fc7ef6..9a8963385a 100644 --- a/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx +++ b/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx @@ -1,6 +1,7 @@ import { QRDisplayWithRegenerateAndStorage, styled } from "@pcd/passport-ui"; import { PCDUI } from "@pcd/pcd-types"; import { PODTicketPCD } from "@pcd/pod-ticket-pcd"; +import { IPODTicketData } from "@pcd/pod-ticket-pcd/src/schema"; import { useCallback } from "react"; import urlJoin from "url-join"; @@ -19,14 +20,28 @@ function PODTicketCardBody({ pcd: PODTicketPCD; idBasedVerifyURL: string; }): JSX.Element { - const ticketData = pcd.claim.ticket; - const hasImage = pcd.claim.ticket.imageUrl !== undefined; + return ( + + ); +} + +export function PODTicketCardBodyImpl({ + ticketData, + idBasedVerifyURL +}: { + ticketData: IPODTicketData; + idBasedVerifyURL: string; +}): JSX.Element { + const hasImage = ticketData.imageUrl !== undefined; return ( {hasImage && ( - + {ticketData?.attendeeName} {ticketData?.attendeeEmail} @@ -34,7 +49,10 @@ function PODTicketCardBody({ {!hasImage && ( <> - + {ticketData.attendeeName} {ticketData.attendeeEmail} @@ -44,12 +62,11 @@ function PODTicketCardBody({ ); } - function TicketQR({ - pcd, + ticketData, idBasedVerifyURL }: { - pcd: PODTicketPCD; + ticketData: IPODTicketData; idBasedVerifyURL: string; }): JSX.Element { const generate = useCallback(async () => { @@ -58,29 +75,29 @@ function TicketQR({ "53edb3e7-6733-41e0-a9be-488877c5c572", // eth berlin "508313ea-f16b-4729-bdf0-281c64493ca9", // eth prague "5074edf5-f079-4099-b036-22223c0c6995" // devcon 7 - ].includes(pcd.claim.ticket.eventId) && - pcd.claim.ticket.ticketSecret + ].includes(ticketData.eventId) && + ticketData.ticketSecret ) { - return pcd.claim.ticket.ticketSecret; + return ticketData.ticketSecret; } return linkToTicket( idBasedVerifyURL, - pcd.claim.ticket.ticketId, - pcd.claim.ticket.eventId + ticketData.ticketId, + ticketData.eventId ); }, [ idBasedVerifyURL, - pcd.claim.ticket.eventId, - pcd.claim.ticket.ticketId, - pcd.claim.ticket.ticketSecret + ticketData.eventId, + ticketData.ticketId, + ticketData.ticketSecret ]); return ( ); } @@ -119,13 +136,13 @@ const TicketInfo = styled.div` `; function TicketImage({ - pcd, + ticketData, hidePadding }: { - pcd: PODTicketPCD; + ticketData: IPODTicketData; hidePadding?: boolean; }): JSX.Element { - const { imageUrl, imageAltText } = pcd.claim.ticket; + const { imageUrl, imageAltText } = ticketData; if (hidePadding) return {imageAltText}; return (