From ca42feffe6b87485e68f8dc42a08a9a89922b1d3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 6 Jun 2024 16:24:21 +0530 Subject: [PATCH] Resurrect --- .../accounts/src/pages/account-handoff.tsx | 8 +- web/apps/accounts/src/pages/passkeys/flow.tsx | 5 +- .../accounts/src/pages/passkeys/index.tsx | 441 ++---------------- .../accounts/src/pages/passkeys/setup.tsx | 441 ++++++++++++++++-- .../photos/src/components/Sidebar/index.tsx | 2 +- web/docs/webauthn-passkeys.md | 2 +- 6 files changed, 448 insertions(+), 451 deletions(-) diff --git a/web/apps/accounts/src/pages/account-handoff.tsx b/web/apps/accounts/src/pages/account-handoff.tsx index d97a6e1e29..6172e7a9f2 100644 --- a/web/apps/accounts/src/pages/account-handoff.tsx +++ b/web/apps/accounts/src/pages/account-handoff.tsx @@ -1,12 +1,12 @@ -import { useRouter } from "next/router"; import { useEffect } from "react"; /** Legacy alias, remove once mobile code is updated (it is still in beta). */ const Page = () => { - const router = useRouter(); - useEffect(() => { - router.push("/passkeys/setup"); + window.location.href = window.location.href.replace( + "account-handoff", + "passkeys", + ); }, []); return <>; diff --git a/web/apps/accounts/src/pages/passkeys/flow.tsx b/web/apps/accounts/src/pages/passkeys/flow.tsx index 391ec63eb8..bd7d5d96be 100644 --- a/web/apps/accounts/src/pages/passkeys/flow.tsx +++ b/web/apps/accounts/src/pages/passkeys/flow.tsx @@ -1,12 +1,9 @@ -import { useRouter } from "next/router"; import { useEffect } from "react"; /** Legacy alias, remove once mobile code is updated (it is still in beta). */ const Page = () => { - const router = useRouter(); - useEffect(() => { - router.push("/passkeys/verify"); + window.location.href = window.location.href.replace("flow", "verify"); }, []); return <>; diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 89ce1702f1..c9d62fdbbf 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -1,418 +1,59 @@ import log from "@/next/log"; -import { ensure } from "@/utils/ensure"; -import { CenteredFlex } from "@ente/shared/components/Container"; -import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; -import EnteButton from "@ente/shared/components/EnteButton"; -import { EnteDrawer } from "@ente/shared/components/EnteDrawer"; -import FormPaper from "@ente/shared/components/Form/FormPaper"; -import InfoItem from "@ente/shared/components/Info/InfoItem"; -import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; -import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider"; -import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup"; -import SingleInputForm from "@ente/shared/components/SingleInputForm"; -import Titlebar from "@ente/shared/components/Titlebar"; -import { getToken } from "@ente/shared/storage/localStorage/helpers"; -import { formatDateTimeFull } from "@ente/shared/time/format"; -import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import DeleteIcon from "@mui/icons-material/Delete"; -import EditIcon from "@mui/icons-material/Edit"; -import KeyIcon from "@mui/icons-material/Key"; -import { Box, Button, Stack, Typography, useMediaQuery } from "@mui/material"; -import { t } from "i18next"; -import _sodium from "libsodium-wrappers"; +import { VerticallyCentered } from "@ente/shared/components/Container"; +import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import HTTPService from "@ente/shared/network/HTTPService"; +import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { useRouter } from "next/router"; -import { useAppContext } from "pages/_app"; -import type { Dispatch, SetStateAction } from "react"; -import { - Fragment, - createContext, - useContext, - useEffect, - useState, -} from "react"; -import { deletePasskey, renamePasskey } from "services/passkey"; -import { - finishPasskeyRegistration, - getPasskeyRegistrationOptions, - getPasskeys, - type Passkey, -} from "../../services/passkey"; - -export const PasskeysContext = createContext( - {} as { - selectedPasskey: Passkey | null; - setSelectedPasskey: Dispatch>; - setShowPasskeyDrawer: Dispatch>; - refreshPasskeys: () => void; - }, -); - -const Passkeys = () => { - const { showNavBar } = useAppContext(); - - const [selectedPasskey, setSelectedPasskey] = useState( - null, - ); - - const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false); - - const [passkeys, setPasskeys] = useState([]); +import { useEffect } from "react"; +const AccountHandoff = () => { const router = useRouter(); - const checkLoggedIn = () => { - const token = getToken(); - if (!token) { + const retrieveAccountData = () => { + try { + extractAccountsToken(); + + router.push("/passkeys"); + } catch (e) { + log.error("Failed to deserialize and set passed user data", e); + // Not much we can do here, but this redirect might be misleading. router.push("/login"); } }; - const init = async () => { - checkLoggedIn(); - const data = await getPasskeys(); - setPasskeys(data.passkeys || []); + const getClientPackageName = () => { + const urlParams = new URLSearchParams(window.location.search); + const pkg = urlParams.get("package"); + if (!pkg) return; + setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg }); + HTTPService.setHeaders({ + "X-Client-Package": pkg, + }); + }; + + const extractAccountsToken = () => { + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get("token"); + if (!token) { + throw new Error("token not found"); + } + + const user = getData(LS_KEYS.USER) || {}; + user.token = token; + + setData(LS_KEYS.USER, user); }; useEffect(() => { - showNavBar(true); - init(); + getClientPackageName(); + retrieveAccountData(); }, []); - const handleSubmit = async ( - inputValue: string, - setFieldError: (errorMessage: string) => void, - resetForm: () => void, - ) => { - let response: { - options: { - publicKey: PublicKeyCredentialCreationOptions; - }; - sessionID: string; - }; - - try { - response = await getPasskeyRegistrationOptions(); - } catch { - setFieldError("Failed to begin registration"); - return; - } - - const options = response.options; - - // TODO-PK: The types don't match. - options.publicKey.challenge = _sodium.from_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - options.publicKey.challenge, - ); - options.publicKey.user.id = _sodium.from_base64( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - options.publicKey.user.id, - ); - - // create new credential - let newCredential: Credential; - - try { - newCredential = ensure(await navigator.credentials.create(options)); - } catch (e) { - log.error("Error creating credential", e); - setFieldError("Failed to create credential"); - return; - } - - try { - await finishPasskeyRegistration( - inputValue, - newCredential, - response.sessionID, - ); - } catch { - setFieldError("Failed to finish registration"); - return; - } - - await init(); - resetForm(); - }; - return ( - - - - - {t("PASSKEYS_DESCRIPTION")} - - - - - - - - - - - + + + ); }; -export default Passkeys; - -interface PasskeysListProps { - passkeys: Passkey[]; -} - -const PasskeysList: React.FC = ({ passkeys }) => { - return ( - - {passkeys.map((passkey, i) => ( - - - {i < passkeys.length - 1 && } - - ))} - - ); -}; - -interface PasskeyListItemProps { - passkey: Passkey; -} - -const PasskeyListItem: React.FC = ({ passkey }) => { - const { setSelectedPasskey, setShowPasskeyDrawer } = - useContext(PasskeysContext); - - return ( - { - setSelectedPasskey(passkey); - setShowPasskeyDrawer(true); - }} - startIcon={} - endIcon={} - label={passkey?.friendlyName} - /> - ); -}; - -interface ManagePasskeyDrawerProps { - open: boolean; -} - -const ManagePasskeyDrawer: React.FC = (props) => { - const { setShowPasskeyDrawer, refreshPasskeys, selectedPasskey } = - useContext(PasskeysContext); - - const [showDeletePasskeyModal, setShowDeletePasskeyModal] = useState(false); - const [showRenamePasskeyModal, setShowRenamePasskeyModal] = useState(false); - - return ( - <> - { - setShowPasskeyDrawer(false); - }} - > - {selectedPasskey && ( - <> - - { - setShowPasskeyDrawer(false); - }} - title="Manage Passkey" - onRootClose={() => { - setShowPasskeyDrawer(false); - }} - /> - } - title={t("CREATED_AT")} - caption={ - `${formatDateTimeFull( - selectedPasskey.createdAt / 1000, - )}` || "" - } - loading={!selectedPasskey} - hideEditOption - /> - - { - setShowRenamePasskeyModal(true); - }} - startIcon={} - label={"Rename Passkey"} - /> - - { - setShowDeletePasskeyModal(true); - }} - startIcon={} - label={"Delete Passkey"} - color="critical" - /> - - - - )} - - { - setShowDeletePasskeyModal(false); - refreshPasskeys(); - }} - /> - { - setShowRenamePasskeyModal(false); - refreshPasskeys(); - }} - /> - - ); -}; - -interface DeletePasskeyModalProps { - open: boolean; - onClose: () => void; -} - -const DeletePasskeyModal: React.FC = (props) => { - const { selectedPasskey, setShowPasskeyDrawer } = - useContext(PasskeysContext); - - const [loading, setLoading] = useState(false); - - const isMobile = useMediaQuery("(max-width: 428px)"); - - const doDelete = async () => { - if (!selectedPasskey) return; - setLoading(true); - try { - await deletePasskey(selectedPasskey.id); - } catch (error) { - console.error(error); - return; - } finally { - setLoading(false); - } - props.onClose(); - setShowPasskeyDrawer(false); - }; - - return ( - - - {t("DELETE_PASSKEY_CONFIRMATION")} - - {t("DELETE")} - - - - - ); -}; - -interface RenamePasskeyModalProps { - open: boolean; - onClose: () => void; -} - -const RenamePasskeyModal: React.FC = (props) => { - const { selectedPasskey } = useContext(PasskeysContext); - - const isMobile = useMediaQuery("(max-width: 428px)"); - - const onSubmit = async (inputValue: string) => { - if (!selectedPasskey) return; - try { - await renamePasskey(selectedPasskey.id, inputValue); - } catch (error) { - console.error(error); - return; - } - - props.onClose(); - }; - - return ( - - - - ); -}; +export default AccountHandoff; diff --git a/web/apps/accounts/src/pages/passkeys/setup.tsx b/web/apps/accounts/src/pages/passkeys/setup.tsx index c9d62fdbbf..89ce1702f1 100644 --- a/web/apps/accounts/src/pages/passkeys/setup.tsx +++ b/web/apps/accounts/src/pages/passkeys/setup.tsx @@ -1,59 +1,418 @@ import log from "@/next/log"; -import { VerticallyCentered } from "@ente/shared/components/Container"; -import EnteSpinner from "@ente/shared/components/EnteSpinner"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; +import { ensure } from "@/utils/ensure"; +import { CenteredFlex } from "@ente/shared/components/Container"; +import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; +import EnteButton from "@ente/shared/components/EnteButton"; +import { EnteDrawer } from "@ente/shared/components/EnteDrawer"; +import FormPaper from "@ente/shared/components/Form/FormPaper"; +import InfoItem from "@ente/shared/components/Info/InfoItem"; +import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem"; +import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider"; +import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup"; +import SingleInputForm from "@ente/shared/components/SingleInputForm"; +import Titlebar from "@ente/shared/components/Titlebar"; +import { getToken } from "@ente/shared/storage/localStorage/helpers"; +import { formatDateTimeFull } from "@ente/shared/time/format"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import KeyIcon from "@mui/icons-material/Key"; +import { Box, Button, Stack, Typography, useMediaQuery } from "@mui/material"; +import { t } from "i18next"; +import _sodium from "libsodium-wrappers"; import { useRouter } from "next/router"; -import { useEffect } from "react"; +import { useAppContext } from "pages/_app"; +import type { Dispatch, SetStateAction } from "react"; +import { + Fragment, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { deletePasskey, renamePasskey } from "services/passkey"; +import { + finishPasskeyRegistration, + getPasskeyRegistrationOptions, + getPasskeys, + type Passkey, +} from "../../services/passkey"; + +export const PasskeysContext = createContext( + {} as { + selectedPasskey: Passkey | null; + setSelectedPasskey: Dispatch>; + setShowPasskeyDrawer: Dispatch>; + refreshPasskeys: () => void; + }, +); + +const Passkeys = () => { + const { showNavBar } = useAppContext(); + + const [selectedPasskey, setSelectedPasskey] = useState( + null, + ); + + const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false); + + const [passkeys, setPasskeys] = useState([]); -const AccountHandoff = () => { const router = useRouter(); - const retrieveAccountData = () => { - try { - extractAccountsToken(); - - router.push("/passkeys"); - } catch (e) { - log.error("Failed to deserialize and set passed user data", e); - // Not much we can do here, but this redirect might be misleading. + const checkLoggedIn = () => { + const token = getToken(); + if (!token) { router.push("/login"); } }; - const getClientPackageName = () => { - const urlParams = new URLSearchParams(window.location.search); - const pkg = urlParams.get("package"); - if (!pkg) return; - setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg }); - HTTPService.setHeaders({ - "X-Client-Package": pkg, - }); - }; - - const extractAccountsToken = () => { - const urlParams = new URLSearchParams(window.location.search); - const token = urlParams.get("token"); - if (!token) { - throw new Error("token not found"); - } - - const user = getData(LS_KEYS.USER) || {}; - user.token = token; - - setData(LS_KEYS.USER, user); + const init = async () => { + checkLoggedIn(); + const data = await getPasskeys(); + setPasskeys(data.passkeys || []); }; useEffect(() => { - getClientPackageName(); - retrieveAccountData(); + showNavBar(true); + init(); }, []); + const handleSubmit = async ( + inputValue: string, + setFieldError: (errorMessage: string) => void, + resetForm: () => void, + ) => { + let response: { + options: { + publicKey: PublicKeyCredentialCreationOptions; + }; + sessionID: string; + }; + + try { + response = await getPasskeyRegistrationOptions(); + } catch { + setFieldError("Failed to begin registration"); + return; + } + + const options = response.options; + + // TODO-PK: The types don't match. + options.publicKey.challenge = _sodium.from_base64( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.publicKey.challenge, + ); + options.publicKey.user.id = _sodium.from_base64( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.publicKey.user.id, + ); + + // create new credential + let newCredential: Credential; + + try { + newCredential = ensure(await navigator.credentials.create(options)); + } catch (e) { + log.error("Error creating credential", e); + setFieldError("Failed to create credential"); + return; + } + + try { + await finishPasskeyRegistration( + inputValue, + newCredential, + response.sessionID, + ); + } catch { + setFieldError("Failed to finish registration"); + return; + } + + await init(); + resetForm(); + }; + return ( - - - + + + + + {t("PASSKEYS_DESCRIPTION")} + + + + + + + + + + + ); }; -export default AccountHandoff; +export default Passkeys; + +interface PasskeysListProps { + passkeys: Passkey[]; +} + +const PasskeysList: React.FC = ({ passkeys }) => { + return ( + + {passkeys.map((passkey, i) => ( + + + {i < passkeys.length - 1 && } + + ))} + + ); +}; + +interface PasskeyListItemProps { + passkey: Passkey; +} + +const PasskeyListItem: React.FC = ({ passkey }) => { + const { setSelectedPasskey, setShowPasskeyDrawer } = + useContext(PasskeysContext); + + return ( + { + setSelectedPasskey(passkey); + setShowPasskeyDrawer(true); + }} + startIcon={} + endIcon={} + label={passkey?.friendlyName} + /> + ); +}; + +interface ManagePasskeyDrawerProps { + open: boolean; +} + +const ManagePasskeyDrawer: React.FC = (props) => { + const { setShowPasskeyDrawer, refreshPasskeys, selectedPasskey } = + useContext(PasskeysContext); + + const [showDeletePasskeyModal, setShowDeletePasskeyModal] = useState(false); + const [showRenamePasskeyModal, setShowRenamePasskeyModal] = useState(false); + + return ( + <> + { + setShowPasskeyDrawer(false); + }} + > + {selectedPasskey && ( + <> + + { + setShowPasskeyDrawer(false); + }} + title="Manage Passkey" + onRootClose={() => { + setShowPasskeyDrawer(false); + }} + /> + } + title={t("CREATED_AT")} + caption={ + `${formatDateTimeFull( + selectedPasskey.createdAt / 1000, + )}` || "" + } + loading={!selectedPasskey} + hideEditOption + /> + + { + setShowRenamePasskeyModal(true); + }} + startIcon={} + label={"Rename Passkey"} + /> + + { + setShowDeletePasskeyModal(true); + }} + startIcon={} + label={"Delete Passkey"} + color="critical" + /> + + + + )} + + { + setShowDeletePasskeyModal(false); + refreshPasskeys(); + }} + /> + { + setShowRenamePasskeyModal(false); + refreshPasskeys(); + }} + /> + + ); +}; + +interface DeletePasskeyModalProps { + open: boolean; + onClose: () => void; +} + +const DeletePasskeyModal: React.FC = (props) => { + const { selectedPasskey, setShowPasskeyDrawer } = + useContext(PasskeysContext); + + const [loading, setLoading] = useState(false); + + const isMobile = useMediaQuery("(max-width: 428px)"); + + const doDelete = async () => { + if (!selectedPasskey) return; + setLoading(true); + try { + await deletePasskey(selectedPasskey.id); + } catch (error) { + console.error(error); + return; + } finally { + setLoading(false); + } + props.onClose(); + setShowPasskeyDrawer(false); + }; + + return ( + + + {t("DELETE_PASSKEY_CONFIRMATION")} + + {t("DELETE")} + + + + + ); +}; + +interface RenamePasskeyModalProps { + open: boolean; + onClose: () => void; +} + +const RenamePasskeyModal: React.FC = (props) => { + const { selectedPasskey } = useContext(PasskeysContext); + + const isMobile = useMediaQuery("(max-width: 428px)"); + + const onSubmit = async (inputValue: string) => { + if (!selectedPasskey) return; + try { + await renamePasskey(selectedPasskey.id, inputValue); + } catch (error) { + console.error(error); + return; + } + + props.onClose(); + }; + + return ( + + + + ); +}; diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index b038111106..189242e020 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -509,7 +509,7 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { const pkg = clientPackageName["photos"]; window.open( - `${accountsAppURL()}/passkeys/setup?package=${pkg}&token=${accountsToken}`, + `${accountsAppURL()}/passkeys?token=${accountsToken}&package=${pkg}`, ); } catch (e) { log.error("failed to redirect to accounts page", e); diff --git a/web/docs/webauthn-passkeys.md b/web/docs/webauthn-passkeys.md index 0156726029..23125a14a2 100644 --- a/web/docs/webauthn-passkeys.md +++ b/web/docs/webauthn-passkeys.md @@ -53,7 +53,7 @@ used.** This restriction is a byproduct of the enablement for automatic login. ### Automatically logging into Accounts Clients open a WebView with the URL -`https://accounts.ente.io/accounts-handoff?token=&package=`. +`https://accounts.ente.io/passkeys?token=&package=`. This page will appear like a normal loading screen to the user, but in the background, the app parses the token and package for usage in subsequent Accounts-related API calls.