Skip to content

feat(fronted): tenanted admin api credentials #3213

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

Merged
merged 30 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c8fd314
feat(frontend): set api credentials on session
BlairCurrey Jan 7, 2025
6bc5f5a
chore(frontend): more details in todo comment
BlairCurrey Jan 7, 2025
f6ef572
refactor(frontend): move credentials form from modal to component on …
BlairCurrey Jan 7, 2025
9bd0f5e
chore(frontend): mark dialog for removal
BlairCurrey Jan 7, 2025
8d06621
feat(frontend): store api creds in server side session
BlairCurrey Jan 8, 2025
a319ca0
feat(frontend): POC for adding tenantId from session to headers for a…
BlairCurrey Jan 9, 2025
2b47182
feat(frontend): form apollo client per request
BlairCurrey Jan 9, 2025
659ceb6
fix(mock-ase): update seed script to pass tenant sig/id verifcation
BlairCurrey Jan 10, 2025
4fffdac
feat(frontend): block api cred form submit on invalid uuid
BlairCurrey Jan 13, 2025
4d0232d
feat(frontend): handle errors, WIP apollo client
BlairCurrey Jan 14, 2025
0f6ef8b
Merge branch '2893/multi-tenancy-v1' into bc/3108/tenanted-admin-ui-r…
BlairCurrey Jan 14, 2025
74e0680
feat(frontend): disable nav links
BlairCurrey Jan 14, 2025
96f2821
docs(localenv): update readme to not say kratos is required
BlairCurrey Jan 14, 2025
6d2be5f
chore(frontend): format
BlairCurrey Jan 14, 2025
4d8b98d
chore(frontend): rm unused component
BlairCurrey Jan 15, 2025
5645a83
chore(frontend): rm commented out code
BlairCurrey Jan 15, 2025
64f1db0
chore(frontend): formatting
BlairCurrey Jan 15, 2025
85e7353
refactor(frontend): better error parsing
BlairCurrey Jan 15, 2025
66f6199
chore(frontend): rm todo
BlairCurrey Jan 15, 2025
092609e
refactor(frontend): use session api for deletion, not manual
BlairCurrey Jan 15, 2025
16a774f
fix(frontend): display error based on message
BlairCurrey Jan 15, 2025
67e43c3
fix(frontend): rm SIGNATURE_SECRET, SIGNATURE_VERSION env vars
BlairCurrey Jan 15, 2025
ffaacff
feat(mock-ase): log operator/tenant details to streamline use of fron…
BlairCurrey Jan 15, 2025
6ba17da
feat(frontend): dont show nav items if api creds required and not set
BlairCurrey Jan 17, 2025
8bf1580
Merge branch '2893/multi-tenancy-v1' into bc/3108/tenanted-admin-ui-r…
BlairCurrey Jan 17, 2025
7896509
feat(frontend): move api credential set action to own endpoint
BlairCurrey Jan 17, 2025
25dd789
feat(frontend): prefill api credential form
BlairCurrey Jan 17, 2025
f5cfa09
chore(frontend): format
BlairCurrey Jan 17, 2025
bb4c694
feat(frontend): auto submit form if values passed in
BlairCurrey Jan 17, 2025
b82bec0
fix: reinstate sig version env var
BlairCurrey Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion localenv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The secondary Happy Life Bank docker compose file (`./happy-life-bank/docker-com
data stores created by the primary Rafiki instance so it can't be run by itself.
The `pnpm localenv:compose up` command starts both the primary instance and the secondary.

See the `frontend` [README](../packages/frontend/README.md#ory-kratos) for more information regarding the Ory Kratos identity and user management system required for Admin UI.
See the `frontend` [README](../packages/frontend/README.md#ory-kratos) for more information regarding the Ory Kratos identity and user management system for the Admin UI.

#### Autopeering

Expand Down
5 changes: 3 additions & 2 deletions localenv/cloud-nine-wallet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ services:
IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
DISPLAY_NAME: Cloud Nine Wallet
DISPLAY_ICON: wallet-icon.svg
OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787
FRONTEND_PORT: 3010
volumes:
- ../cloud-nine-wallet/seed.yml:/workspace/seed.yml
- ../cloud-nine-wallet/private-key.pem:/workspace/private-key.pem
Expand Down Expand Up @@ -166,9 +168,8 @@ services:
GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql
OPEN_PAYMENTS_URL: https://cloud-nine-wallet-backend/
ENABLE_INSECURE_MESSAGE_COOKIE: true
AUTH_ENABLED: false
AUTH_ENABLED: false
SIGNATURE_VERSION: 1
SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
depends_on:
- cloud-nine-backend

Expand Down
3 changes: 2 additions & 1 deletion localenv/happy-life-bank/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ services:
IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
DISPLAY_NAME: Happy Life Bank
DISPLAY_ICON: bank-icon.svg
OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d
FRONTEND_PORT: 4010
volumes:
- ../happy-life-bank/seed.yml:/workspace/seed.yml
- ../happy-life-bank/private-key.pem:/workspace/private-key.pem
Expand Down Expand Up @@ -136,7 +138,6 @@ services:
ENABLE_INSECURE_MESSAGE_COOKIE: true
AUTH_ENABLED: false
SIGNATURE_VERSION: 1
SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
depends_on:
- cloud-nine-admin
- happy-life-backend
22 changes: 22 additions & 0 deletions localenv/mock-account-servicing-entity/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ async function callWithRetry(fn: () => any, depth = 0): Promise<void> {
}

if (!global.__seeded) {
const tenantId = process.env.OPERATOR_TENANT_ID
const apiSecret = process.env.SIGNATURE_SECRET
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem like SIGNATURE_SECRET is set in the environment anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was removed from the frontend - is that what you're referring to? The docker compose sets them for the mock ase. When I spin up rafiki it doesnt error on the lines below for the secret, and it spits out a link with the apiSecret.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, got it. My mistake.


if (!tenantId || !apiSecret) {
throw new Error(
'Must set OPERATOR_TENANT_ID and SIGNATURE_SECRET environment variables'
)
}

callWithRetry(async () => {
console.log('setting up from seed...')
return setupFromSeed(CONFIG, apolloClient, mockAccounts, {
Expand All @@ -39,6 +48,19 @@ if (!global.__seeded) {
})
.then(() => {
global.__seeded = true
setTimeout(() => {
const url = new URL(`http://localhost:${process.env.FRONTEND_PORT}/`)
const params = new URLSearchParams({
tenantId,
apiSecret
})

url.search = params.toString()

console.log(
`Local Dev Setup:\nUse this URL to access the frontend with operator tenant credentials:\n${url}\n`
)
}, 2000)
})
.catch((e) => {
console.log(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ const authLink = setContext((request, { headers }) => {
return {
headers: {
...headers,
signature: `t=${timestamp}, v${version}=${digest}`
signature: `t=${timestamp}, v${version}=${digest}`,
['tenant-id']: process.env.OPERATOR_TENANT_ID
}
}
})
Expand Down
113 changes: 113 additions & 0 deletions packages/frontend/app/components/ApiCredentialsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Form, useActionData, useNavigation } from '@remix-run/react'
import { useRef, useState, useEffect } from 'react'
import { Input, Button } from '~/components/ui'
import { validate as validateUUID } from 'uuid'

interface ApiCredentialsFormProps {
showClearCredentials: boolean
defaultTenantId: string
defaultApiSecret: string
}

interface ActionErrorResponse {
status: number
statusText: string
}

export const ApiCredentialsForm = ({
showClearCredentials,
defaultTenantId,
defaultApiSecret
}: ApiCredentialsFormProps) => {
const actionData = useActionData<ActionErrorResponse>()
const navigation = useNavigation()
const inputRef = useRef<HTMLInputElement>(null)
const formRef = useRef<HTMLFormElement>(null)
const [tenantIdError, setTenantIdError] = useState<string | null>(null)

const isSubmitting = navigation.state === 'submitting'

const handleTenantIdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const tenantId = event.target.value.trim()

if (tenantId === '') {
setTenantIdError('Tenant ID is required')
} else if (!validateUUID(tenantId)) {
setTenantIdError('Invalid Tenant ID (must be a valid UUID)')
} else {
setTenantIdError(null)
}
}

// auto submit form if values passed in
useEffect(() => {
if (defaultTenantId && defaultApiSecret && !tenantIdError) {
if (formRef.current) {
formRef.current.submit()
}
}
}, [defaultTenantId, defaultApiSecret, tenantIdError])

return (
<div className='space-y-4'>
{showClearCredentials ? (
<Form method='post' action='/api/set-credentials' className='space-y-4'>
<p className='text-green-600'>✓ API credentials configured</p>
<input hidden readOnly name='intent' value='clear' />
<Button
type='submit'
intent='danger'
aria-label='Clear API credentials'
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Clear Credentials'}
</Button>
</Form>
) : (
<Form
method='post'
action='/api/set-credentials'
className='space-y-4'
ref={formRef} // Reference for the credentials form
>
<Input
ref={inputRef}
required
type='text'
name='tenantId'
label='Tenant ID'
defaultValue={defaultTenantId}
onChange={handleTenantIdChange}
aria-invalid={!!tenantIdError}
aria-describedby={tenantIdError ? 'tenantId-error' : undefined}
/>
{tenantIdError && (
<p id='tenantId-error' className='text-red-500 text-sm'>
{tenantIdError}
</p>
)}
<Input
required
type='password'
name='apiSecret'
label='API Secret'
defaultValue={defaultApiSecret}
/>
<input hidden readOnly name='intent' value='save' />
<div className='flex justify-center'>
<Button
type='submit'
aria-label='Save API credentials'
disabled={!!tenantIdError || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Save Credentials'}
</Button>
</div>
</Form>
)}
{actionData?.statusText && (
<div className='text-red-500'>{actionData.statusText}</div>
)}
</div>
)
}
15 changes: 12 additions & 3 deletions packages/frontend/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Button } from '~/components/ui'
interface SidebarProps {
logoutUrl: string
authEnabled: boolean
hasApiCredentials: boolean
}

const navigation = [
Expand Down Expand Up @@ -38,9 +39,17 @@ const navigation = [
}
]

export const Sidebar: FC<SidebarProps> = ({ logoutUrl, authEnabled }) => {
export const Sidebar: FC<SidebarProps> = ({
logoutUrl,
authEnabled,
hasApiCredentials
}) => {
const [sidebarIsOpen, setSidebarIsOpen] = useState(false)

const navigationToShow = hasApiCredentials
? navigation
: navigation.filter(({ name }) => name === 'Home')

return (
<>
<Transition.Root show={sidebarIsOpen} as={Fragment}>
Expand Down Expand Up @@ -81,7 +90,7 @@ export const Sidebar: FC<SidebarProps> = ({ logoutUrl, authEnabled }) => {
<div className='mt-5 h-0 flex-1 overflow-y-auto'>
<nav className='px-2'>
<div className='space-y-1'>
{navigation.map(({ name, href }) => (
{navigationToShow.map(({ name, href }) => (
<NavLink
key={name}
to={href}
Expand Down Expand Up @@ -140,7 +149,7 @@ export const Sidebar: FC<SidebarProps> = ({ logoutUrl, authEnabled }) => {
{/* Desktop Navigation */}
<div className='hidden w-full mt-5 flex-1 flex-col overflow-y-auto md:block'>
<div className='space-y-2'>
{navigation.map(({ name, href }) => (
{navigationToShow.map(({ name, href }) => (
<NavLink
key={name}
to={href}
Expand Down
35 changes: 24 additions & 11 deletions packages/frontend/app/lib/api/asset.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ import type {
WithdrawAssetLiquidity,
WithdrawAssetLiquidityVariables
} from '~/generated/graphql'
import { apolloClient } from '../apollo.server'
import { getApolloClient } from '../apollo.server'

export const getAssetInfo = async (args: QueryAssetArgs) => {
export const getAssetInfo = async (request: Request, args: QueryAssetArgs) => {
const apolloClient = await getApolloClient(request)
Comment on lines +32 to +33
Copy link
Contributor Author

@BlairCurrey BlairCurrey Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example of changs to apollo client to support requestor specific api signature and tenant id header as described here: https://github.com/interledger/rafiki/pull/3213/files#r1916826388

The gql requests exported by these api/*.server.ts files (getAssetInfo etc.) are called by our loaders and actions (server side). We pass in the request which has the cookie so that we can form the authLink using the correct signature and tenant id per request.

const response = await apolloClient.query<
GetAssetQuery,
GetAssetQueryVariables
Expand All @@ -56,7 +57,11 @@ export const getAssetInfo = async (args: QueryAssetArgs) => {
return response.data.asset
}

export const getAssetWithFees = async (args: QueryAssetArgs) => {
export const getAssetWithFees = async (
request: Request,
args: QueryAssetArgs
) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.query<
GetAssetWithFeesQuery,
GetAssetWithFeesQueryVariables
Expand Down Expand Up @@ -97,7 +102,8 @@ export const getAssetWithFees = async (args: QueryAssetArgs) => {
return response.data.asset
}

export const listAssets = async (args: QueryAssetsArgs) => {
export const listAssets = async (request: Request, args: QueryAssetsArgs) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.query<
ListAssetsQuery,
ListAssetsQueryVariables
Expand Down Expand Up @@ -130,11 +136,11 @@ export const listAssets = async (args: QueryAssetsArgs) => {
`,
variables: args
})

return response.data.assets
}

export const createAsset = async (args: CreateAssetInput) => {
export const createAsset = async (request: Request, args: CreateAssetInput) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.mutate<
CreateAssetMutation,
CreateAssetMutationVariables
Expand Down Expand Up @@ -166,7 +172,8 @@ export const createAsset = async (args: CreateAssetInput) => {
return response.data?.createAsset
}

export const updateAsset = async (args: UpdateAssetInput) => {
export const updateAsset = async (request: Request, args: UpdateAssetInput) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.mutate<
UpdateAssetMutation,
UpdateAssetMutationVariables
Expand Down Expand Up @@ -198,7 +205,8 @@ export const updateAsset = async (args: UpdateAssetInput) => {
return response.data?.updateAsset
}

export const setFee = async (args: SetFeeInput) => {
export const setFee = async (request: Request, args: SetFeeInput) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.mutate<
SetFeeMutation,
SetFeeMutationVariables
Expand Down Expand Up @@ -226,8 +234,10 @@ export const setFee = async (args: SetFeeInput) => {
}

export const depositAssetLiquidity = async (
request: Request,
args: DepositAssetLiquidityInput
) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.mutate<
DepositAssetLiquidityMutation,
DepositAssetLiquidityMutationVariables
Expand All @@ -250,8 +260,10 @@ export const depositAssetLiquidity = async (
}

export const withdrawAssetLiquidity = async (
request: Request,
args: CreateAssetLiquidityWithdrawalInput
) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.mutate<
WithdrawAssetLiquidity,
WithdrawAssetLiquidityVariables
Expand All @@ -273,13 +285,13 @@ export const withdrawAssetLiquidity = async (
return response.data?.createAssetLiquidityWithdrawal
}

export const loadAssets = async () => {
export const loadAssets = async (request: Request) => {
let assets: ListAssetsQuery['assets']['edges'] = []
let hasNextPage = true
let after: string | undefined

while (hasNextPage) {
const response = await listAssets({ first: 100, after })
const response = await listAssets(request, { first: 100, after })

if (!response.edges.length) {
return []
Expand All @@ -295,7 +307,8 @@ export const loadAssets = async () => {
return assets
}

export const deleteAsset = async (args: DeleteAssetInput) => {
export const deleteAsset = async (request: Request, args: DeleteAssetInput) => {
const apolloClient = await getApolloClient(request)
const response = await apolloClient.mutate<
DeleteAssetMutation,
DeleteAssetMutationVariables
Expand Down
Loading
Loading