Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(passport): identity merge #2847

Merged
merged 11 commits into from
Mar 15, 2024
23 changes: 21 additions & 2 deletions apps/console/app/utilities/session.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ import {
Session,
} from '@remix-run/cloudflare'

import { decryptSession } from '@proofzero/utils/session'
import createCoreClient from '@proofzero/platform-clients/core'
import {
generateTraceContextHeaders,
generateTraceSpan,
} from '@proofzero/platform-middleware/trace'

import { IdentityURNSpace } from '@proofzero/urns/identity'
import { decryptSession } from '@proofzero/utils/session'
import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils'
import {
checkToken,
ExpiredTokenError,
Expand Down Expand Up @@ -63,7 +70,19 @@ export async function requireJWT(request: Request, env: Env) {
const jwt = await getUserSession(request, env)

try {
checkToken(jwt)
const { sub: subject } = checkToken(jwt)
if (!subject) throw InvalidTokenError

const coreClient = createCoreClient(env.Core, {
...getAuthzHeaderConditionallyFromToken(jwt),
...generateTraceContextHeaders(generateTraceSpan()),
})

if (
!IdentityURNSpace.is(subject) ||
!(await coreClient.identity.isValid.query())
)
throw InvalidTokenError
return jwt
} catch (error) {
switch (error) {
Expand Down
6 changes: 5 additions & 1 deletion apps/passport/app/routes/authorize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -238,7 +242,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
const responseType = ResponseType.Code
const preauthorizeRes =
await coreClient.authorization.preauthorize.mutate({
identity: identityURN,
identityURN,
responseType,
clientId,
redirectUri,
Expand Down
16 changes: 16 additions & 0 deletions apps/passport/app/routes/merge-identity/cancel.tsx
Original file line number Diff line number Diff line change
@@ -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 })
}
)
234 changes: 234 additions & 0 deletions apps/passport/app/routes/merge-identity/confirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
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<LoaderData>()
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 (
<>
<div className={`${dark ? 'dark' : ''}`}>
<div className="flex flex-row h-[100dvh] justify-center items-center bg-[#F9FAFB] dark:bg-gray-900">
<div
className="basis-2/5 h-[100dvh] w-full hidden lg:flex justify-center items-center bg-indigo-50 dark:bg-[#1F2937] overflow-hidden"
style={{
backgroundImage: `url(${sideGraphics})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
<div className="basis-full lg:basis-3/5">
<div className="flex flex-col w-[402px] min-h-fit m-auto p-6 space-y-8 box-border bg-white dark:bg-[#1F2937] border rounded-lg border-[#D1D5DB] dark:border-gray-600">
<div className="flex flex-col space-y-4">
<div className="flex justify-center space-y-4">
<img
src={dangerVector}
className="inline-block w-[48px] h-[48px] mr-4"
alt="danger"
/>
</div>
<div className="flex flex-col justify-content space-y-2">
<Text
size="xl"
weight="semibold"
className="leading-8 text-center text-[#2D333A] dark:text-white"
>
Confirm Identity Merge
</Text>
<Text
type="span"
className="leading-6 text-center text-orange-600"
>
This action permanently transfers <br />
<Text type="span" weight="semibold">
all accounts
</Text>{' '}
from one identity to other.
</Text>
</div>
<div className="flex flex-col items-center space-y-2">
<User
avatar={source.avatar}
displayName={source.displayName}
primaryAccountAlias={source.primaryAccountAlias}
accounts={source.accounts}
applications={source.applications}
/>
<ImArrowDown color="#D1D5DB" size={32} />
<User
avatar={target.avatar}
displayName={target.displayName}
primaryAccountAlias={target.primaryAccountAlias}
accounts={target.accounts}
applications={target.applications}
/>
</div>
<div className="flex justify-between">
<Form method="get" action="/merge-identity/cancel">
<Button
type="submit"
btnType="secondary-alt"
className="w-40"
>
Cancel
</Button>
</Form>
<fetcher.Form method="post" action="/merge-identity/merge">
<Button
type="submit"
btnType="primary-alt"
className="w-40"
>
Confirm Merge
</Button>
</fetcher.Form>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}

type UserProps = {
avatar: string
displayName: string
primaryAccountAlias: string
accounts: number
applications: number
}

const User = ({
avatar,
displayName,
primaryAccountAlias,
accounts,
applications,
}: UserProps) => {
return (
<div className="w-[350px] border rounded-md border-gray-200 dark:border-gray-600">
<div className="min-w-full">
<div className="flex p-2 space-x-2 border-b border-gray-200 dark:border-gray-600">
<img
src={avatar}
alt="avatar"
className="w-[40px] h-[40px] rounded-full"
/>
<div>
<Text
size="sm"
weight="semibold"
className="leading-5 text-gray-600 dark:text-white"
>
{displayName}
</Text>
<Text
size="sm"
weight="normal"
className="leading-5 text-gray-400 dark:text-[#6B7280]"
>
{primaryAccountAlias}
</Text>
</div>
</div>
<div>
<div className="p-2 space-y-2 leading-5 text-gray-500 dark:text-[#6B7280]">
<div>
<Text type="span" size="sm" weight="semibold">
Accounts:{' '}
<Text type="span" size="sm" weight="normal">
{accounts}
</Text>
</Text>
</div>
<div>
<Text type="span" size="sm" weight="semibold">
Applications:{' '}
<Text type="span" size="sm" weight="normal">
{applications}
</Text>
</Text>
</div>
</div>
</div>
</div>
</div>
)
}
6 changes: 6 additions & 0 deletions apps/passport/app/routes/merge-identity/index.tsx
Original file line number Diff line number Diff line change
@@ -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')
}
71 changes: 71 additions & 0 deletions apps/passport/app/routes/merge-identity/merge.tsx
Original file line number Diff line number Diff line change
@@ -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 })
}
)
Loading
Loading