Skip to content

Commit 1bb2a9b

Browse files
authored
feat(fronted): tenanted admin api credentials (#3213)
* feat(frontend): set api credentials on session * chore(frontend): more details in todo comment * refactor(frontend): move credentials form from modal to component on index * chore(frontend): mark dialog for removal - not removing yet because not sure if we might end up using it. could be useful if we want to make global redirect if this is not set. * feat(frontend): store api creds in server side session * feat(frontend): POC for adding tenantId from session to headers for all apollo requests Uses the assets and list asset query. This POC passes the request to the listAsset function. Which imports the apolloClient directly and passes the cookie from request headers in the context. To avoid having to set this on each query as we compose it, my intention is to create a new getApolloClient function and use that insteadof directly importing a single client. This enables us to form a link to handle setting the headers per request (as opposed to static links that are used across all requests as it is currently). * feat(frontend): form apollo client per request - enables authLink to get tenantId, apiSecret from cookie in request - wondered if this was a performance concern (maybe why we had single instance before?) but found several things indicating this is OK and even recommended: - apollographql/apollo-client#9520 (comment) - https://www.apollographql.com/blog/how-to-use-apollo-client-with-remix * fix(mock-ase): update seed script to pass tenant sig/id verifcation * feat(frontend): block api cred form submit on invalid uuid * feat(frontend): handle errors, WIP apollo client - see TODOs in apollo client in frontend. maybe need to remove some env vars and verify how no tenantid/secret are handled * feat(frontend): disable nav links * docs(localenv): update readme to not say kratos is required * chore(frontend): format * chore(frontend): rm unused component * chore(frontend): rm commented out code * chore(frontend): formatting * refactor(frontend): better error parsing * chore(frontend): rm todo * refactor(frontend): use session api for deletion, not manual * fix(frontend): display error based on message reverses previous commit to use apollo error. proved unreliable * fix(frontend): rm SIGNATURE_SECRET, SIGNATURE_VERSION env vars * feat(mock-ase): log operator/tenant details to streamline use of frontend * feat(frontend): dont show nav items if api creds required and not set * feat(frontend): move api credential set action to own endpoint - removes the action from the index. the intention is to expose the remix server port over docker and call this from the MASE to set the api credentials on start * feat(frontend): prefill api credential form * chore(frontend): format * feat(frontend): auto submit form if values passed in requires changing intent to be set as an input. submitting form bypasses the button so the action didnt have the intent and failed when auto submitting. * fix: reinstate sig version env var
1 parent 8ade26a commit 1bb2a9b

39 files changed

+479
-121
lines changed

localenv/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ The secondary Happy Life Bank docker compose file (`./happy-life-bank/docker-com
121121
data stores created by the primary Rafiki instance so it can't be run by itself.
122122
The `pnpm localenv:compose up` command starts both the primary instance and the secondary.
123123

124-
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.
124+
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.
125125

126126
#### Autopeering
127127

localenv/cloud-nine-wallet/docker-compose.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ services:
2525
IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
2626
DISPLAY_NAME: Cloud Nine Wallet
2727
DISPLAY_ICON: wallet-icon.svg
28+
OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787
29+
FRONTEND_PORT: 3010
2830
volumes:
2931
- ../cloud-nine-wallet/seed.yml:/workspace/seed.yml
3032
- ../cloud-nine-wallet/private-key.pem:/workspace/private-key.pem
@@ -166,9 +168,8 @@ services:
166168
GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql
167169
OPEN_PAYMENTS_URL: https://cloud-nine-wallet-backend/
168170
ENABLE_INSECURE_MESSAGE_COOKIE: true
169-
AUTH_ENABLED: false
171+
AUTH_ENABLED: false
170172
SIGNATURE_VERSION: 1
171-
SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
172173
depends_on:
173174
- cloud-nine-backend
174175

localenv/happy-life-bank/docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ services:
2121
IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
2222
DISPLAY_NAME: Happy Life Bank
2323
DISPLAY_ICON: bank-icon.svg
24+
OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d
25+
FRONTEND_PORT: 4010
2426
volumes:
2527
- ../happy-life-bank/seed.yml:/workspace/seed.yml
2628
- ../happy-life-bank/private-key.pem:/workspace/private-key.pem
@@ -136,7 +138,6 @@ services:
136138
ENABLE_INSECURE_MESSAGE_COOKIE: true
137139
AUTH_ENABLED: false
138140
SIGNATURE_VERSION: 1
139-
SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
140141
depends_on:
141142
- cloud-nine-admin
142143
- happy-life-backend

localenv/mock-account-servicing-entity/app/entry.server.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ async function callWithRetry(fn: () => any, depth = 0): Promise<void> {
3030
}
3131

3232
if (!global.__seeded) {
33+
const tenantId = process.env.OPERATOR_TENANT_ID
34+
const apiSecret = process.env.SIGNATURE_SECRET
35+
36+
if (!tenantId || !apiSecret) {
37+
throw new Error(
38+
'Must set OPERATOR_TENANT_ID and SIGNATURE_SECRET environment variables'
39+
)
40+
}
41+
3342
callWithRetry(async () => {
3443
console.log('setting up from seed...')
3544
return setupFromSeed(CONFIG, apolloClient, mockAccounts, {
@@ -39,6 +48,19 @@ if (!global.__seeded) {
3948
})
4049
.then(() => {
4150
global.__seeded = true
51+
setTimeout(() => {
52+
const url = new URL(`http://localhost:${process.env.FRONTEND_PORT}/`)
53+
const params = new URLSearchParams({
54+
tenantId,
55+
apiSecret
56+
})
57+
58+
url.search = params.toString()
59+
60+
console.log(
61+
`Local Dev Setup:\nUse this URL to access the frontend with operator tenant credentials:\n${url}\n`
62+
)
63+
}, 2000)
4264
})
4365
.catch((e) => {
4466
console.log(

localenv/mock-account-servicing-entity/app/lib/apolloClient.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ const authLink = setContext((request, { headers }) => {
6868
return {
6969
headers: {
7070
...headers,
71-
signature: `t=${timestamp}, v${version}=${digest}`
71+
signature: `t=${timestamp}, v${version}=${digest}`,
72+
['tenant-id']: process.env.OPERATOR_TENANT_ID
7273
}
7374
}
7475
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Form, useActionData, useNavigation } from '@remix-run/react'
2+
import { useRef, useState, useEffect } from 'react'
3+
import { Input, Button } from '~/components/ui'
4+
import { validate as validateUUID } from 'uuid'
5+
6+
interface ApiCredentialsFormProps {
7+
showClearCredentials: boolean
8+
defaultTenantId: string
9+
defaultApiSecret: string
10+
}
11+
12+
interface ActionErrorResponse {
13+
status: number
14+
statusText: string
15+
}
16+
17+
export const ApiCredentialsForm = ({
18+
showClearCredentials,
19+
defaultTenantId,
20+
defaultApiSecret
21+
}: ApiCredentialsFormProps) => {
22+
const actionData = useActionData<ActionErrorResponse>()
23+
const navigation = useNavigation()
24+
const inputRef = useRef<HTMLInputElement>(null)
25+
const formRef = useRef<HTMLFormElement>(null)
26+
const [tenantIdError, setTenantIdError] = useState<string | null>(null)
27+
28+
const isSubmitting = navigation.state === 'submitting'
29+
30+
const handleTenantIdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
31+
const tenantId = event.target.value.trim()
32+
33+
if (tenantId === '') {
34+
setTenantIdError('Tenant ID is required')
35+
} else if (!validateUUID(tenantId)) {
36+
setTenantIdError('Invalid Tenant ID (must be a valid UUID)')
37+
} else {
38+
setTenantIdError(null)
39+
}
40+
}
41+
42+
// auto submit form if values passed in
43+
useEffect(() => {
44+
if (defaultTenantId && defaultApiSecret && !tenantIdError) {
45+
if (formRef.current) {
46+
formRef.current.submit()
47+
}
48+
}
49+
}, [defaultTenantId, defaultApiSecret, tenantIdError])
50+
51+
return (
52+
<div className='space-y-4'>
53+
{showClearCredentials ? (
54+
<Form method='post' action='/api/set-credentials' className='space-y-4'>
55+
<p className='text-green-600'>✓ API credentials configured</p>
56+
<input hidden readOnly name='intent' value='clear' />
57+
<Button
58+
type='submit'
59+
intent='danger'
60+
aria-label='Clear API credentials'
61+
disabled={isSubmitting}
62+
>
63+
{isSubmitting ? 'Submitting...' : 'Clear Credentials'}
64+
</Button>
65+
</Form>
66+
) : (
67+
<Form
68+
method='post'
69+
action='/api/set-credentials'
70+
className='space-y-4'
71+
ref={formRef} // Reference for the credentials form
72+
>
73+
<Input
74+
ref={inputRef}
75+
required
76+
type='text'
77+
name='tenantId'
78+
label='Tenant ID'
79+
defaultValue={defaultTenantId}
80+
onChange={handleTenantIdChange}
81+
aria-invalid={!!tenantIdError}
82+
aria-describedby={tenantIdError ? 'tenantId-error' : undefined}
83+
/>
84+
{tenantIdError && (
85+
<p id='tenantId-error' className='text-red-500 text-sm'>
86+
{tenantIdError}
87+
</p>
88+
)}
89+
<Input
90+
required
91+
type='password'
92+
name='apiSecret'
93+
label='API Secret'
94+
defaultValue={defaultApiSecret}
95+
/>
96+
<input hidden readOnly name='intent' value='save' />
97+
<div className='flex justify-center'>
98+
<Button
99+
type='submit'
100+
aria-label='Save API credentials'
101+
disabled={!!tenantIdError || isSubmitting}
102+
>
103+
{isSubmitting ? 'Submitting...' : 'Save Credentials'}
104+
</Button>
105+
</div>
106+
</Form>
107+
)}
108+
{actionData?.statusText && (
109+
<div className='text-red-500'>{actionData.statusText}</div>
110+
)}
111+
</div>
112+
)
113+
}

packages/frontend/app/components/Sidebar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Button } from '~/components/ui'
99
interface SidebarProps {
1010
logoutUrl: string
1111
authEnabled: boolean
12+
hasApiCredentials: boolean
1213
}
1314

1415
const navigation = [
@@ -38,9 +39,17 @@ const navigation = [
3839
}
3940
]
4041

41-
export const Sidebar: FC<SidebarProps> = ({ logoutUrl, authEnabled }) => {
42+
export const Sidebar: FC<SidebarProps> = ({
43+
logoutUrl,
44+
authEnabled,
45+
hasApiCredentials
46+
}) => {
4247
const [sidebarIsOpen, setSidebarIsOpen] = useState(false)
4348

49+
const navigationToShow = hasApiCredentials
50+
? navigation
51+
: navigation.filter(({ name }) => name === 'Home')
52+
4453
return (
4554
<>
4655
<Transition.Root show={sidebarIsOpen} as={Fragment}>
@@ -81,7 +90,7 @@ export const Sidebar: FC<SidebarProps> = ({ logoutUrl, authEnabled }) => {
8190
<div className='mt-5 h-0 flex-1 overflow-y-auto'>
8291
<nav className='px-2'>
8392
<div className='space-y-1'>
84-
{navigation.map(({ name, href }) => (
93+
{navigationToShow.map(({ name, href }) => (
8594
<NavLink
8695
key={name}
8796
to={href}
@@ -140,7 +149,7 @@ export const Sidebar: FC<SidebarProps> = ({ logoutUrl, authEnabled }) => {
140149
{/* Desktop Navigation */}
141150
<div className='hidden w-full mt-5 flex-1 flex-col overflow-y-auto md:block'>
142151
<div className='space-y-2'>
143-
{navigation.map(({ name, href }) => (
152+
{navigationToShow.map(({ name, href }) => (
144153
<NavLink
145154
key={name}
146155
to={href}

packages/frontend/app/lib/api/asset.server.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ import type {
2727
WithdrawAssetLiquidity,
2828
WithdrawAssetLiquidityVariables
2929
} from '~/generated/graphql'
30-
import { apolloClient } from '../apollo.server'
30+
import { getApolloClient } from '../apollo.server'
3131

32-
export const getAssetInfo = async (args: QueryAssetArgs) => {
32+
export const getAssetInfo = async (request: Request, args: QueryAssetArgs) => {
33+
const apolloClient = await getApolloClient(request)
3334
const response = await apolloClient.query<
3435
GetAssetQuery,
3536
GetAssetQueryVariables
@@ -56,7 +57,11 @@ export const getAssetInfo = async (args: QueryAssetArgs) => {
5657
return response.data.asset
5758
}
5859

59-
export const getAssetWithFees = async (args: QueryAssetArgs) => {
60+
export const getAssetWithFees = async (
61+
request: Request,
62+
args: QueryAssetArgs
63+
) => {
64+
const apolloClient = await getApolloClient(request)
6065
const response = await apolloClient.query<
6166
GetAssetWithFeesQuery,
6267
GetAssetWithFeesQueryVariables
@@ -97,7 +102,8 @@ export const getAssetWithFees = async (args: QueryAssetArgs) => {
97102
return response.data.asset
98103
}
99104

100-
export const listAssets = async (args: QueryAssetsArgs) => {
105+
export const listAssets = async (request: Request, args: QueryAssetsArgs) => {
106+
const apolloClient = await getApolloClient(request)
101107
const response = await apolloClient.query<
102108
ListAssetsQuery,
103109
ListAssetsQueryVariables
@@ -130,11 +136,11 @@ export const listAssets = async (args: QueryAssetsArgs) => {
130136
`,
131137
variables: args
132138
})
133-
134139
return response.data.assets
135140
}
136141

137-
export const createAsset = async (args: CreateAssetInput) => {
142+
export const createAsset = async (request: Request, args: CreateAssetInput) => {
143+
const apolloClient = await getApolloClient(request)
138144
const response = await apolloClient.mutate<
139145
CreateAssetMutation,
140146
CreateAssetMutationVariables
@@ -166,7 +172,8 @@ export const createAsset = async (args: CreateAssetInput) => {
166172
return response.data?.createAsset
167173
}
168174

169-
export const updateAsset = async (args: UpdateAssetInput) => {
175+
export const updateAsset = async (request: Request, args: UpdateAssetInput) => {
176+
const apolloClient = await getApolloClient(request)
170177
const response = await apolloClient.mutate<
171178
UpdateAssetMutation,
172179
UpdateAssetMutationVariables
@@ -198,7 +205,8 @@ export const updateAsset = async (args: UpdateAssetInput) => {
198205
return response.data?.updateAsset
199206
}
200207

201-
export const setFee = async (args: SetFeeInput) => {
208+
export const setFee = async (request: Request, args: SetFeeInput) => {
209+
const apolloClient = await getApolloClient(request)
202210
const response = await apolloClient.mutate<
203211
SetFeeMutation,
204212
SetFeeMutationVariables
@@ -226,8 +234,10 @@ export const setFee = async (args: SetFeeInput) => {
226234
}
227235

228236
export const depositAssetLiquidity = async (
237+
request: Request,
229238
args: DepositAssetLiquidityInput
230239
) => {
240+
const apolloClient = await getApolloClient(request)
231241
const response = await apolloClient.mutate<
232242
DepositAssetLiquidityMutation,
233243
DepositAssetLiquidityMutationVariables
@@ -250,8 +260,10 @@ export const depositAssetLiquidity = async (
250260
}
251261

252262
export const withdrawAssetLiquidity = async (
263+
request: Request,
253264
args: CreateAssetLiquidityWithdrawalInput
254265
) => {
266+
const apolloClient = await getApolloClient(request)
255267
const response = await apolloClient.mutate<
256268
WithdrawAssetLiquidity,
257269
WithdrawAssetLiquidityVariables
@@ -273,13 +285,13 @@ export const withdrawAssetLiquidity = async (
273285
return response.data?.createAssetLiquidityWithdrawal
274286
}
275287

276-
export const loadAssets = async () => {
288+
export const loadAssets = async (request: Request) => {
277289
let assets: ListAssetsQuery['assets']['edges'] = []
278290
let hasNextPage = true
279291
let after: string | undefined
280292

281293
while (hasNextPage) {
282-
const response = await listAssets({ first: 100, after })
294+
const response = await listAssets(request, { first: 100, after })
283295

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

298-
export const deleteAsset = async (args: DeleteAssetInput) => {
310+
export const deleteAsset = async (request: Request, args: DeleteAssetInput) => {
311+
const apolloClient = await getApolloClient(request)
299312
const response = await apolloClient.mutate<
300313
DeleteAssetMutation,
301314
DeleteAssetMutationVariables

0 commit comments

Comments
 (0)