Skip to content

Commit

Permalink
feat(passport): identity merge (#2847)
Browse files Browse the repository at this point in the history
  • Loading branch information
szkl authored Mar 15, 2024
1 parent 23b9627 commit 4788f90
Show file tree
Hide file tree
Showing 40 changed files with 1,352 additions and 46 deletions.
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

0 comments on commit 4788f90

Please sign in to comment.