diff --git a/packages/keychain/next.config.js b/packages/keychain/next.config.js index 499141eab..ff54c4a6b 100644 --- a/packages/keychain/next.config.js +++ b/packages/keychain/next.config.js @@ -20,6 +20,25 @@ const nextConfig = { config.experiments = { ...config.experiments, asyncWebAssembly: true }; return config; }, + redirects: async function () { + return [ + { + source: '/slot/auth', + destination: '/slot', + permanent: true + }, + { + source: '/slot/auth/success', + destination: '/success', + permanent: true + }, + { + source: '/slot/auth/failure', + destination: '/failure', + permanent: true + } + ] + } }; module.exports = nextConfig; diff --git a/packages/keychain/src/components/connect/CreateController.tsx b/packages/keychain/src/components/connect/CreateController.tsx index 8d5c82e2d..81b00398e 100644 --- a/packages/keychain/src/components/connect/CreateController.tsx +++ b/packages/keychain/src/components/connect/CreateController.tsx @@ -8,9 +8,11 @@ import { isSignedUp } from "../../utils/cookie"; export function CreateController({ isSlot, loginMode, + onCreated, }: { isSlot?: boolean; loginMode?: LoginMode; + onCreated?: () => void; }) { const { error } = useConnection(); const [showSignup, setShowSignup] = useState(true); @@ -32,6 +34,7 @@ export function CreateController({ onLogin={(username) => { setPrefilledUsername(username); setShowSignup(false); + onCreated?.(); }} isSlot={isSlot} /> @@ -41,6 +44,7 @@ export function CreateController({ onSignup={(username) => { setPrefilledUsername(username); setShowSignup(true); + onCreated?.(); }} mode={loginMode} isSlot={isSlot} diff --git a/packages/keychain/src/pages/slot/auth/failure.tsx b/packages/keychain/src/pages/failure.tsx similarity index 88% rename from packages/keychain/src/pages/slot/auth/failure.tsx rename to packages/keychain/src/pages/failure.tsx index 9d9a84b0d..2ba986660 100644 --- a/packages/keychain/src/pages/slot/auth/failure.tsx +++ b/packages/keychain/src/pages/failure.tsx @@ -1,11 +1,12 @@ +"use client"; + import { Container } from "components/layout"; import { AlertIcon, ExternalIcon } from "@cartridge/ui"; import { Link, Text } from "@chakra-ui/react"; import NextLink from "next/link"; -import dynamic from "next/dynamic"; import { CARTRIDGE_DISCORD_LINK } from "const"; -function Failure() { +export default function Failure() { return ( ); } - -export default dynamic(() => Promise.resolve(Failure), { ssr: false }); diff --git a/packages/keychain/src/pages/login.tsx b/packages/keychain/src/pages/login.tsx index 7657b0f5e..0eb0d832e 100644 --- a/packages/keychain/src/pages/login.tsx +++ b/packages/keychain/src/pages/login.tsx @@ -1,18 +1,29 @@ +"use client"; + import { useRouter } from "next/router"; import { Login as LoginComponent } from "components/connect"; import { useConnection } from "hooks/connection"; -import dynamic from "next/dynamic"; -function Login() { +export default function Login() { const router = useRouter(); const { controller, rpcUrl, chainId, error } = useConnection(); + const navigateToSuccess = () => { + router.replace({ + pathname: "/success", + query: { + title: "Logged in!", + description: "Your controller is ready", + }, + }); + }; + if (error) { return <>{error.message}; } if (controller) { - router.replace(`${process.env.NEXT_PUBLIC_ADMIN_URL}/profile`); + navigateToSuccess(); } if (!rpcUrl || !chainId) { @@ -22,11 +33,7 @@ function Login() { return ( router.push({ pathname: "/signup", query: router.query })} - onSuccess={async () => { - router.replace(`${process.env.NEXT_PUBLIC_ADMIN_URL}/profile`); - }} + onSuccess={navigateToSuccess} /> ); } - -export default dynamic(() => Promise.resolve(Login), { ssr: false }); diff --git a/packages/keychain/src/pages/session.tsx b/packages/keychain/src/pages/session.tsx new file mode 100644 index 000000000..59e10b793 --- /dev/null +++ b/packages/keychain/src/pages/session.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { Policy } from "@cartridge/controller"; +import { + CreateController, + CreateSession as CreateSessionComp, +} from "components/connect"; + +import { useConnection } from "hooks/connection"; +import { useRouter } from "next/router"; +import { useCallback, useEffect } from "react"; +import { Call, hash } from "starknet"; +import { LoginMode } from "components/connect/types"; +import base64url from "base64url"; + +type SessionQueryParams = Record & { + callback_uri?: string; + redirect_uri?: string; + redirect_query_name?: string; +}; + +/** + This page is for creating session +*/ +export default function CreateRemoteSession() { + const router = useRouter(); + const queries = router.query as SessionQueryParams; + + const { controller, policies, origin } = useConnection(); + + // Handler for calling the callback uri. + // Send the session details to the callback uri in the body of the + // POST request. If the request is successful, then redirect to the + // success page. Else, redirect to the failure page. + const onCallback = useCallback(() => { + const session = controller.account.sessionJson(); + if ((!queries.callback_uri && !queries.redirect_uri) || !session) { + router.replace(`/failure`); + return; + } + + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + + const credentialsJson = JSON.stringify({ + username: controller.username, + credentials: { + publicKey: controller.publicKey, + credentialId: controller.credentialId, + }, + session, + }); + + if (queries.callback_uri) { + fetch(sanitizeCallbackUrl(decodeURIComponent(queries.callback_uri)), { + body: credentialsJson, + headers, + method: "POST", + }) + .then(async (res) => { + if (res.ok) { + return router.replace({ + pathname: "/success", + query: { + title: "Seession Created!", + description: "Return to your terminal to continue", + }, + }); + } + + Promise.reject(); + }) + .catch((e) => { + console.error("failed to call the callback url", e); + router.replace(`/failure`); + }); + } + + if (queries.redirect_uri) { + router.replace( + `${decodeURIComponent(queries.redirect_uri)}?${ + queries.redirect_query_name ?? "session" + }=${base64url.encode(credentialsJson)}`, + ); + } + }, [ + router, + queries.callback_uri, + queries.redirect_uri, + queries.redirect_query_name, + controller, + ]); + + // Handler when user clicks the Create button + const onConnect = useCallback( + (_: Policy[]) => { + if (!controller.account.sessionJson()) { + throw new Error("Session not found"); + } + + if (!queries.callback_uri && !queries.redirect_uri) { + throw new Error("Expected either callback_uri or redirect_uri"); + } + + onCallback(); + }, + [queries.callback_uri, queries.redirect_uri, controller, onCallback], + ); + + // Once we have a connected controller initialized, check if a session already exists. + // If yes, check if the policies of the session are the same as the ones that are + // currently being requested. Return existing session to the callback uri if policies match. + useEffect(() => { + if (!controller || !origin) { + return; + } + + let calls = policies.map((policy) => { + return { + contractAddress: policy.target, + entrypoint: hash.getSelector(policy.method), + calldata: [], + } as Call; + }); + + // if the requested policies has no mismatch with existing policies then return + // the exising session + if (controller.account.hasSession(calls)) { + onCallback(); + } + }, [controller, origin, policies, onCallback]); + + return controller ? ( + + ) : ( + + ); +} + +/** + * Sanitize the callback url to ensure that it is a valid URL. Returns back the URL. + */ +function sanitizeCallbackUrl(url: string): URL | undefined { + try { + const parsed = new URL(url); + + if ( + parsed.hostname.endsWith("cartridge.gg") && + parsed.pathname !== "/" && + parsed.pathname !== "/callback" + ) { + throw new Error(`Invalid callback url: ${url}`); + } + + return parsed; + } catch (e) { + console.error(e); + } +} diff --git a/packages/keychain/src/pages/signup.tsx b/packages/keychain/src/pages/signup.tsx index 3615fe6db..6e711eec3 100644 --- a/packages/keychain/src/pages/signup.tsx +++ b/packages/keychain/src/pages/signup.tsx @@ -1,18 +1,29 @@ +"use client"; + import { useRouter } from "next/router"; import { Signup as SignupComponent } from "components/connect"; import { useConnection } from "hooks/connection"; -import dynamic from "next/dynamic"; -function Signup() { +export default function Signup() { const router = useRouter(); const { controller, rpcUrl, chainId, error } = useConnection(); + const navigateToSuccess = (title: string) => { + router.replace({ + pathname: "/success", + query: { + title, + description: "Your controller is ready", + }, + }); + }; + if (error) { return <>{error.message}; } if (controller) { - router.replace(`${process.env.NEXT_PUBLIC_ADMIN_URL}/profile`); + navigateToSuccess("Already signed up!"); } if (!rpcUrl || !chainId) { @@ -22,11 +33,7 @@ function Signup() { return ( router.push({ pathname: "/login", query: router.query })} - onSuccess={() => { - router.replace(`${process.env.NEXT_PUBLIC_ADMIN_URL}/profile`); - }} + onSuccess={() => navigateToSuccess("Signed up!")} /> ); } - -export default dynamic(() => Promise.resolve(Signup), { ssr: false }); diff --git a/packages/keychain/src/pages/slot/auth/success.tsx b/packages/keychain/src/pages/slot/auth/success.tsx deleted file mode 100644 index cba058b10..000000000 --- a/packages/keychain/src/pages/slot/auth/success.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Container } from "components/layout"; -import { SparklesDuoIcon } from "@cartridge/ui"; -import dynamic from "next/dynamic"; - -function Success() { - return ( - - ); -} - -export default dynamic(() => Promise.resolve(Success), { ssr: false }); diff --git a/packages/keychain/src/pages/slot/auth/consent.tsx b/packages/keychain/src/pages/slot/consent.tsx similarity index 100% rename from packages/keychain/src/pages/slot/auth/consent.tsx rename to packages/keychain/src/pages/slot/consent.tsx diff --git a/packages/keychain/src/pages/slot/auth/index.tsx b/packages/keychain/src/pages/slot/index.tsx similarity index 94% rename from packages/keychain/src/pages/slot/auth/index.tsx rename to packages/keychain/src/pages/slot/index.tsx index cf90fb903..c98a9ccc9 100644 --- a/packages/keychain/src/pages/slot/auth/index.tsx +++ b/packages/keychain/src/pages/slot/index.tsx @@ -19,7 +19,7 @@ function Auth() { "", ); - router.replace(`/slot/auth/consent${query}`); + router.replace(`/slot/consent${query}`); } }, [user, controller, router]); diff --git a/packages/keychain/src/pages/slot/auth/me.graphql b/packages/keychain/src/pages/slot/me.graphql similarity index 100% rename from packages/keychain/src/pages/slot/auth/me.graphql rename to packages/keychain/src/pages/slot/me.graphql diff --git a/packages/keychain/src/pages/slot/session/index.tsx b/packages/keychain/src/pages/slot/session/index.tsx deleted file mode 100644 index ddbb2ed42..000000000 --- a/packages/keychain/src/pages/slot/session/index.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { Policy } from "@cartridge/controller"; -import Controller from "utils/controller"; -import { CreateSession as CreateSessionComp } from "components/connect"; - -import { fetchAccount } from "components/connect/utils"; -import { useConnection } from "hooks/connection"; -import { useRouter } from "next/router"; -import { useCallback, useEffect, useState } from "react"; -import { PageLoading } from "components/Loading"; -import dynamic from "next/dynamic"; -import { Call, hash } from "starknet"; - -type SessionQueryParams = Record & { - callback_uri: string; - username: string; -}; - -/** - This page is for creating session with Slot -*/ -function CreateSession() { - const router = useRouter(); - const queries = router.query as SessionQueryParams; - - const { controller, setController, policies, origin, chainId, rpcUrl } = - useConnection(); - - // Fetching account status for displaying the loading screen - const [isFetching, setIsFetching] = useState(true); - - // Handler for calling the Slot callback uri. - // Send the session details to the callback uri in the body of the - // POST request. If the request is successful, then redirect to the - // success page. Else, redirect to the failure page. - const onSlotCallback = useCallback(() => { - const url = sanitizeCallbackUrl(decodeURIComponent(queries.callback_uri)); - const session = controller.account.sessionJson(); - if (!url || !session) { - router.replace(`/slot/auth/failure`); - return; - } - - const headers = new Headers(); - headers.append("Content-Type", "application/json"); - - fetch(url, { - body: JSON.stringify(session), - headers, - method: "POST", - }) - .then(async (res) => { - return res.status === 200 - ? router.replace(`/slot/auth/success`) - : new Promise((_, reject) => reject(res)); - }) - .catch((e) => { - console.error("failed to call the callback url", e); - router.replace(`/slot/auth/failure`); - }); - }, [router, queries.callback_uri, controller]); - - // Handler when user clicks the Create button - const onConnect = useCallback( - (_: Policy[]) => { - if (!controller.account.sessionJson()) { - throw new Error("Session not found"); - } - - if (!queries.callback_uri) { - throw new Error("Callback URI is missing"); - } - - onSlotCallback(); - }, - [queries.callback_uri, controller, onSlotCallback], - ); - - // Fetch account details from the username, and create the Controller - useEffect(() => { - const username = queries.username; - - if (!username || !chainId || !rpcUrl) { - return; - } - - fetchAccount(username) - .then((res) => { - const { - account: { - credentials: { - webauthn: [{ id: credentialId, publicKey }], - }, - contractAddress: address, - }, - } = res; - - const controller = new Controller({ - appId: origin, - chainId, - rpcUrl, - address, - username, - publicKey, - credentialId, - }); - - setController(controller); - }) - .catch((e) => console.error(e)) - .finally(() => setIsFetching(false)); - }, [rpcUrl, chainId, origin, setController, queries.username]); - - // Once the controller is created upon start, check if a session already exists. - // If yes, check if the policies of the session are the same as the ones that are - // currently being requested. Return existing session to the callback uri if policies match. - useEffect(() => { - if (!controller || !origin) { - return; - } - - let calls = policies.map((policy) => { - return { - contractAddress: policy.target, - entrypoint: hash.getSelector(policy.method), - calldata: [], - } as Call; - }); - - // if the requested policies has no mismatch with existing policies then return - // the exising session - if (controller.account.hasSession(calls)) { - onSlotCallback(); - } - }, [controller, origin, policies, onSlotCallback]); - - // Show loader if currently fetching account - if (isFetching) { - return ; - } - - return ; -} - -/** - * Sanitize the callback url to ensure that it is a valid URL. Returns back the URL. - */ -function sanitizeCallbackUrl(url: string): URL | undefined { - try { - const parsed = new URL(url); - - if (parsed.hostname !== "localhost" || parsed.pathname !== "/callback") { - throw new Error(`Invalid callback url: ${url}`); - } - - return parsed; - } catch (e) { - console.error(e); - } -} - -export default dynamic(() => Promise.resolve(CreateSession), { ssr: false }); diff --git a/packages/keychain/src/pages/success.tsx b/packages/keychain/src/pages/success.tsx new file mode 100644 index 000000000..67f60d801 --- /dev/null +++ b/packages/keychain/src/pages/success.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Container } from "components/layout"; +import { SparklesDuoIcon } from "@cartridge/ui"; +import { useRouter } from "next/router"; + +export default function Success() { + const router = useRouter(); + const { title, description } = router.query; + + return ( + + ); +} diff --git a/packages/keychain/src/utils/controller.ts b/packages/keychain/src/utils/controller.ts index 2905db956..75502c4fc 100644 --- a/packages/keychain/src/utils/controller.ts +++ b/packages/keychain/src/utils/controller.ts @@ -29,8 +29,8 @@ export default class Controller { public chainId: string; public rpcUrl: string; public signer: SignerInterface; - protected publicKey: string; - protected credentialId: string; + public publicKey: string; + public credentialId: string; constructor({ appId,