From d8b62f1ab4ee2a35ccc5d10206d5bdbc741c1094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88nmez=20Kartal?= Date: Tue, 27 Feb 2024 02:45:43 +0300 Subject: [PATCH] feat(identity): identity merge --- apps/passport/app/routes/authorize.tsx | 4 + .../app/routes/merge-identity/cancel.tsx | 16 ++ .../app/routes/merge-identity/confirm.tsx | 230 ++++++++++++++++++ .../app/routes/merge-identity/index.tsx | 6 + .../app/routes/merge-identity/merge.tsx | 71 ++++++ .../app/routes/merge-identity/prompt.tsx | 174 +++++++++++++ apps/passport/app/session.server.ts | 56 ++++- .../passport/app/utils/authenticate.server.ts | 27 +- .../src/hooks/useConnectResult.tsx | 2 + 9 files changed, 573 insertions(+), 13 deletions(-) create mode 100644 apps/passport/app/routes/merge-identity/cancel.tsx create mode 100644 apps/passport/app/routes/merge-identity/confirm.tsx create mode 100644 apps/passport/app/routes/merge-identity/index.tsx create mode 100644 apps/passport/app/routes/merge-identity/merge.tsx create mode 100644 apps/passport/app/routes/merge-identity/prompt.tsx diff --git a/apps/passport/app/routes/authorize.tsx b/apps/passport/app/routes/authorize.tsx index 09bcd52a20..7a2d709a2a 100644 --- a/apps/passport/app/routes/authorize.tsx +++ b/apps/passport/app/routes/authorize.tsx @@ -81,6 +81,10 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( const connectResult = new URL(request.url).searchParams.get('rollup_result') ?? undefined + if (connectResult === 'ACCOUNT_LINKED_ERROR') { + throw redirect('/merge-identity') + } + //Request parameter pre-checks if (!clientId) throw new BadRequestError({ message: 'client_id is required' }) diff --git a/apps/passport/app/routes/merge-identity/cancel.tsx b/apps/passport/app/routes/merge-identity/cancel.tsx new file mode 100644 index 0000000000..8c680faeca --- /dev/null +++ b/apps/passport/app/routes/merge-identity/cancel.tsx @@ -0,0 +1,16 @@ +import { redirect, type LoaderFunction } from '@remix-run/cloudflare' + +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' + +import { destroyIdentityMergeState } from '~/session.server' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const headers = new Headers() + headers.append( + 'Set-Cookie', + await destroyIdentityMergeState(request, context.env) + ) + return redirect('/authenticate/cancel', { headers }) + } +) diff --git a/apps/passport/app/routes/merge-identity/confirm.tsx b/apps/passport/app/routes/merge-identity/confirm.tsx new file mode 100644 index 0000000000..c380fb3385 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/confirm.tsx @@ -0,0 +1,230 @@ +import { useContext, useEffect } from 'react' +import { type LoaderFunction } from '@remix-run/cloudflare' +import { useFetcher, useLoaderData, Form } from '@remix-run/react' + +import { ImArrowDown } from 'react-icons/im' + +import { BadRequestError } from '@proofzero/errors' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' + +import { Button } from '@proofzero/design-system/src/atoms/buttons/Button' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { ToastType, toast } from '@proofzero/design-system/src/atoms/toast' +import { ThemeContext } from '@proofzero/design-system/src/contexts/theme' + +import sideGraphics from '~/assets/auth-side-graphics.svg' +import dangerVector from '~/assets/warning.svg' + +import { getCoreClient } from '~/platform.server' + +import { + getIdentityMergeState, + getValidatedSessionContext, +} from '~/session.server' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const mergeIdentityState = await getIdentityMergeState(request, context.env) + if (!mergeIdentityState) + throw new BadRequestError({ + message: 'missing merge identity state', + }) + + const { source, target } = mergeIdentityState + + const { jwt, identityURN } = await getValidatedSessionContext( + request, + context.authzQueryParams, + context.env, + context.traceSpan + ) + + if (identityURN !== target) + throw new BadRequestError({ + message: 'invalid merge identity state', + }) + + const coreClient = getCoreClient({ + context, + jwt, + }) + + const preview = await coreClient.identity.mergePreview.query({ + source, + target, + }) + + return { + source: preview.source, + target: preview.target, + } + } +) + +type LoaderData = { + source: UserProps + target: UserProps +} + +export default function Confirm() { + const { dark } = useContext(ThemeContext) + const { source, target } = useLoaderData() + const fetcher = useFetcher<{ error?: { message: string } }>() + + useEffect(() => { + if (fetcher.state !== 'idle') return + if (fetcher.type !== 'done') return + if (!fetcher.data) return + if (!fetcher.data.error) return + toast(ToastType.Error, fetcher.data.error, { duration: 2000 }) + }, [fetcher]) + + return ( + <> +
+
+
+
+
+
+
+ danger +
+
+ + Confirm Identity Merge + + + This action permanently transfers
+ + all accounts + {' '} + from one identity to other. +
+
+
+ + + +
+
+
+ +
+ + + +
+
+
+
+
+
+ + ) +} + +type UserProps = { + avatar: string + displayName: string + primaryAccountAlias: string + accounts: number + applications: number +} + +const User = ({ + avatar, + displayName, + primaryAccountAlias, + accounts, + applications, +}: UserProps) => { + return ( +
+
+
+ avatar +
+ + {displayName} + + + {primaryAccountAlias} + +
+
+
+
+
+ + Accounts:{' '} + + {accounts} + + +
+
+ + Applications:{' '} + + {applications} + + +
+
+
+
+
+ ) +} diff --git a/apps/passport/app/routes/merge-identity/index.tsx b/apps/passport/app/routes/merge-identity/index.tsx new file mode 100644 index 0000000000..acffcd3680 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/index.tsx @@ -0,0 +1,6 @@ +import { redirect } from '@remix-run/cloudflare' +import type { LoaderFunction } from '@remix-run/cloudflare' + +export const loader: LoaderFunction = () => { + return redirect('/merge-identity/prompt') +} diff --git a/apps/passport/app/routes/merge-identity/merge.tsx b/apps/passport/app/routes/merge-identity/merge.tsx new file mode 100644 index 0000000000..3fadd722c6 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/merge.tsx @@ -0,0 +1,71 @@ +import { redirect, type ActionFunction } from '@remix-run/cloudflare' + +import { BadRequestError, ConflictError } from '@proofzero/errors' +import { + getErrorCause, + getRollupReqFunctionErrorWrapper, +} from '@proofzero/utils/errors' + +import { getCoreClient } from '~/platform.server' + +import { + destroyIdentityMergeState, + getAuthzCookieParams, + getIdentityMergeState, + getValidatedSessionContext, +} from '~/session.server' + +import { getAuthzRedirectURL } from '~/utils/authenticate.server' + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const mergeIdentityState = await getIdentityMergeState(request, context.env) + if (!mergeIdentityState) + throw new BadRequestError({ + message: 'missing merge identity state', + }) + + const { source, target } = mergeIdentityState + + const { jwt, identityURN } = await getValidatedSessionContext( + request, + context.authzQueryParams, + context.env, + context.traceSpan + ) + + if (identityURN !== target) { + destroyIdentityMergeState(request, context.env) + throw new BadRequestError({ + message: 'invalid merge identity state', + }) + } + + const coreClient = getCoreClient({ + context, + jwt, + }) + + try { + await coreClient.identity.merge.mutate({ source, target }) + } catch (e) { + const error = getErrorCause(e) + if (error instanceof ConflictError) + return { + error: { + message: error.message, + }, + } + else throw error + } + + const headers = new Headers() + headers.append( + 'Set-Cookie', + await destroyIdentityMergeState(request, context.env) + ) + + const params = await getAuthzCookieParams(request, context.env) + return redirect(getAuthzRedirectURL(params), { headers }) + } +) diff --git a/apps/passport/app/routes/merge-identity/prompt.tsx b/apps/passport/app/routes/merge-identity/prompt.tsx new file mode 100644 index 0000000000..3ecb5a1b29 --- /dev/null +++ b/apps/passport/app/routes/merge-identity/prompt.tsx @@ -0,0 +1,174 @@ +import { useContext, useState } from 'react' +import { redirect } from '@remix-run/cloudflare' +import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare' +import { useLoaderData, Form } from '@remix-run/react' + +import { Button } from '@proofzero/design-system/src/atoms/buttons/Button' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { ThemeContext } from '@proofzero/design-system/src/contexts/theme' + +import { BadRequestError } from '@proofzero/errors' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' + +import { AccountURNSpace } from '@proofzero/urns/account' + +import sideGraphics from '~/assets/auth-side-graphics.svg' +import dangerVector from '~/assets/warning.svg' + +import { getIdentityMergeState } from '~/session.server' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const mergeIdentityState = await getIdentityMergeState(request, context.env) + if (!mergeIdentityState) + throw new BadRequestError({ + message: 'missing merge identity state', + }) + + const { account } = mergeIdentityState + const alias = AccountURNSpace.componentizedParse(account).qcomponent?.alias + return { alias } + } +) + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request }) => { + const body = await request.formData() + const action = body.get('action') + if (action === 'cancel') return redirect('/merge-identity/cancel') + else if (action === 'confirm') return redirect('/merge-identity/confirm') + } +) + +type LoaderData = { + alias: string +} + +export default function Prompt() { + const { dark } = useContext(ThemeContext) + const { alias } = useLoaderData() + const [selectedOption, setSelectedOption] = useState('cancel') + + return ( + <> +
+
+
+
+
+
+
+
+ danger +
+
+
+ + Account{' '} + + {alias} + {' '} + is already connected to different identity. + + + How would you like to continue? + +
+
+
+
+ +
+ +
+
+
+
+
+
+
+ + ) +} + +type OptionProps = { + action: string + title: string + checked?: boolean + onChange: () => unknown + children?: JSX.Element +} + +const Option = ({ + action, + title, + checked = false, + onChange, + children, +}: OptionProps) => ( + +) diff --git a/apps/passport/app/session.server.ts b/apps/passport/app/session.server.ts index 4d729065d2..44696e1c68 100644 --- a/apps/passport/app/session.server.ts +++ b/apps/passport/app/session.server.ts @@ -19,8 +19,8 @@ import { encryptSession, decryptSession } from '@proofzero/utils/session' import { getCoreClient } from './platform.server' import type { TraceSpan } from '@proofzero/platform-middleware/trace' import { InternalServerError, UnauthorizedError } from '@proofzero/errors' -import { IdentityURNSpace } from '@proofzero/urns/identity' -import type { IdentityURN } from '@proofzero/urns/identity' +import { type AccountURN } from '@proofzero/urns/account' +import { type IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' import { FLASH_MESSAGE, FLASH_MESSAGE_KEY } from './utils/flashMessage.server' import { getCookieDomain } from './utils/cookie' @@ -29,6 +29,58 @@ export const InvalidSessionIdentityError = new UnauthorizedError({ message: 'Session identity is not valid', }) +// IDENTITY MERGE STATE + +export const getIdentityMergeState = async (request: Request, env: Env) => { + const storage = getIdentityMergeStateStorage(request, env) + const session = await storage.getSession(request.headers.get('Cookie')) + return { + account: session.get('account'), + source: session.get('source'), + target: session.get('target'), + } +} + +export const createIdentityMergeState = async ( + request: Request, + env: Env, + account: AccountURN, + source: IdentityURN, + target: IdentityURN +) => { + const storage = getIdentityMergeStateStorage(request, env) + const session = await storage.getSession() + session.set('account', account) + session.set('source', source) + session.set('target', target) + return storage.commitSession(session) +} + +export const destroyIdentityMergeState = async (request: Request, env: Env) => { + const storage = getIdentityMergeStateStorage(request, env) + const session = await getIdentityMergeStateSession(request, env) + return storage.destroySession(session) +} + +const getIdentityMergeStateSession = (request: Request, env: Env) => { + const storage = getIdentityMergeStateStorage(request, env) + return storage.getSession(request.headers.get('Cookie')) +} + +const getIdentityMergeStateStorage = (request: Request, env: Env) => { + return createCookieSessionStorage({ + cookie: { + name: '_rollup_identity_merge', + domain: getCookieDomain(request, env), + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV == 'production', + httpOnly: true, + secrets: [env.SECRET_SESSION_SALT], + }, + }) +} + // FLASH SESSION const getFlashSessionStorage = (request: Request, env: Env) => { diff --git a/apps/passport/app/utils/authenticate.server.ts b/apps/passport/app/utils/authenticate.server.ts index 2b592f1e7c..4c6f5a4820 100644 --- a/apps/passport/app/utils/authenticate.server.ts +++ b/apps/passport/app/utils/authenticate.server.ts @@ -1,6 +1,5 @@ -import { AccountURNSpace } from '@proofzero/urns/account' -import type { AccountURN } from '@proofzero/urns/account' -import type { IdentityURN } from '@proofzero/urns/identity' +import { type AccountURN, AccountURNSpace } from '@proofzero/urns/account' +import { type IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' import { GrantType, ResponseType } from '@proofzero/types/authorization' @@ -20,6 +19,8 @@ import { createAuthenticatorSessionStorage, } from '~/auth.server' +import { createIdentityMergeState } from '~/session.server' + export const authenticateAccount = async ( account: AccountURN, identity: IdentityURN, @@ -42,6 +43,7 @@ export const authenticateAccount = async ( (['connect', 'reconnect'].includes(appData?.rollup_action) || appData?.rollup_action.startsWith('groupconnect')) ) { + const headers = new Headers() let result = undefined if ( @@ -49,17 +51,20 @@ export const authenticateAccount = async ( (appData.rollup_action === 'connect' || appData.rollup_action.startsWith('groupconnect')) ) { - const loggedInIdentity = parseJwt(jwt).sub - if (identity !== loggedInIdentity) { - result = 'ACCOUNT_CONNECT_ERROR' - } else { - result = 'ALREADY_CONNECTED_ERROR' + const source = identity + const target = parseJwt(jwt).sub + if (!target) result = 'ACCOUNT_CONNECT_ERROR' + else if (source === target) result = 'ALREADY_CONNECTED_ERROR' + else if (IdentityURNSpace.is(target) && source !== target) { + result = 'ACCOUNT_LINKED_ERROR' + headers.append( + 'Set-Cookie', + await createIdentityMergeState(request, env, account, source, target) + ) } } - const redirectURL = getAuthzRedirectURL(appData, result) - - return redirect(redirectURL) + return redirect(getAuthzRedirectURL(appData, result), { headers }) } const context = { env: { Core: env.Core }, traceSpan } diff --git a/packages/design-system/src/hooks/useConnectResult.tsx b/packages/design-system/src/hooks/useConnectResult.tsx index 2318f5536d..50c13f4bec 100644 --- a/packages/design-system/src/hooks/useConnectResult.tsx +++ b/packages/design-system/src/hooks/useConnectResult.tsx @@ -41,6 +41,8 @@ export default ( { duration: 2000 } ) break + case 'ACCOUNT_LINKED_ERROR': + break case 'CANCEL': toast( ToastType.Warning,