diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 5296c25729..31b6c2cff5 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -1454,6 +1454,8 @@ export type Tenant = Model & { idpConsentUrl?: Maybe; /** Secret used to secure requests from the tenant's identity provider. */ idpSecret?: Maybe; + /** Is the tenant an Operator tenant. */ + isOperator: Scalars['Boolean']['output']; /** Public name for the tenant. */ publicName?: Maybe; }; @@ -2544,6 +2546,7 @@ export type TenantResolvers; idpConsentUrl?: Resolver, ParentType, ContextType>; idpSecret?: Resolver, ParentType, ContextType>; + isOperator?: Resolver; publicName?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index f441f81038..efcaca00e0 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -8081,6 +8081,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isOperator", + "description": "Is the tenant an Operator tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "publicName", "description": "Public name for the tenant.", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 5296c25729..31b6c2cff5 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -1454,6 +1454,8 @@ export type Tenant = Model & { idpConsentUrl?: Maybe; /** Secret used to secure requests from the tenant's identity provider. */ idpSecret?: Maybe; + /** Is the tenant an Operator tenant. */ + isOperator: Scalars['Boolean']['output']; /** Public name for the tenant. */ publicName?: Maybe; }; @@ -2544,6 +2546,7 @@ export type TenantResolvers; idpConsentUrl?: Resolver, ParentType, ContextType>; idpSecret?: Resolver, ParentType, ContextType>; + isOperator?: Resolver; publicName?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/src/graphql/resolvers/tenant.ts b/packages/backend/src/graphql/resolvers/tenant.ts index 19e86bd077..fafd95ffab 100644 --- a/packages/backend/src/graphql/resolvers/tenant.ts +++ b/packages/backend/src/graphql/resolvers/tenant.ts @@ -10,6 +10,7 @@ import { GraphQLErrorCode } from '../errors' import { Tenant } from '../../tenants/model' import { Pagination, SortOrder } from '../../shared/baseModel' import { getPageInfo } from '../../shared/pagination' +import { Config } from '../../config/app' export const whoami: QueryResolvers['whoami'] = async ( parent, @@ -174,6 +175,7 @@ export function tenantToGraphQl(tenant: Tenant): SchemaTenant { createdAt: new Date(+tenant.createdAt).toISOString(), deletedAt: tenant.deletedAt ? new Date(+tenant.deletedAt).toISOString() - : null + : null, + isOperator: tenant.apiSecret === Config.adminApiSecret } } diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 78218b01a1..b73e960170 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1551,6 +1551,8 @@ type Tenant implements Model { createdAt: String! "The date and time that this tenant was deleted." deletedAt: String + "Is the tenant an Operator tenant." + isOperator: Boolean! } type TenantsConnection { diff --git a/packages/frontend/app/components/Sidebar.tsx b/packages/frontend/app/components/Sidebar.tsx index 8555bae1ab..5e37ef29d5 100644 --- a/packages/frontend/app/components/Sidebar.tsx +++ b/packages/frontend/app/components/Sidebar.tsx @@ -17,6 +17,10 @@ const navigation = [ name: 'Home', href: '/' }, + { + name: 'Tenants', + href: '/tenants' + }, { name: 'Assets', href: '/assets' diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index e7c149d3d2..2c4d6669c4 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -1454,6 +1454,8 @@ export type Tenant = Model & { idpConsentUrl?: Maybe; /** Secret used to secure requests from the tenant's identity provider. */ idpSecret?: Maybe; + /** Is the tenant an Operator tenant. */ + isOperator: Scalars['Boolean']['output']; /** Public name for the tenant. */ publicName?: Maybe; }; @@ -2544,6 +2546,7 @@ export type TenantResolvers; idpConsentUrl?: Resolver, ParentType, ContextType>; idpSecret?: Resolver, ParentType, ContextType>; + isOperator?: Resolver; publicName?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2921,6 +2924,49 @@ export type WithdrawPeerLiquidityVariables = Exact<{ export type WithdrawPeerLiquidity = { __typename?: 'Mutation', createPeerLiquidityWithdrawal?: { __typename?: 'LiquidityMutationResponse', success: boolean } | null }; +export type ListTenantsQueryVariables = Exact<{ + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}>; + + +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, isOperator: boolean } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; + +export type CreateTenantMutationVariables = Exact<{ + input: CreateTenantInput; +}>; + + +export type CreateTenantMutation = { __typename?: 'Mutation', createTenant: { __typename?: 'TenantMutationResponse', tenant: { __typename?: 'Tenant', id: string, publicName?: string | null, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null } } }; + +export type UpdateTenantMutationVariables = Exact<{ + input: UpdateTenantInput; +}>; + + +export type UpdateTenantMutation = { __typename?: 'Mutation', updateTenant: { __typename?: 'TenantMutationResponse', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null } } }; + +export type DeleteTenantMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type DeleteTenantMutation = { __typename?: 'Mutation', deleteTenant: { __typename?: 'DeleteTenantMutationResponse', success: boolean } }; + +export type GetTenantQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, isOperator: boolean } }; + +export type WhoAmIQueryVariables = Exact<{ [key: string]: never; }>; + + +export type WhoAmIQuery = { __typename?: 'Query', whoami: { __typename?: 'WhoamiResponse', id: string, isOperator: boolean } }; + export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; }>; diff --git a/packages/frontend/app/lib/api/tenant.server.ts b/packages/frontend/app/lib/api/tenant.server.ts new file mode 100644 index 0000000000..b4139b4b1f --- /dev/null +++ b/packages/frontend/app/lib/api/tenant.server.ts @@ -0,0 +1,186 @@ +import { gql } from '@apollo/client' +import type { + CreateTenantInput, + CreateTenantMutation, + UpdateTenantMutationVariables, + UpdateTenantInput, + UpdateTenantMutation, + CreateTenantMutationVariables, + QueryTenantsArgs, + ListTenantsQuery, + ListTenantsQueryVariables, + DeleteTenantMutationVariables, + DeleteTenantMutation, + QueryTenantArgs, + GetTenantQuery, + GetTenantQueryVariables +} from '~/generated/graphql' +import { getApolloClient } from '../apollo.server' + +export const listTenants = async (request: Request, args: QueryTenantsArgs) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.query< + ListTenantsQuery, + ListTenantsQueryVariables + >({ + query: gql` + query ListTenantsQuery( + $after: String + $before: String + $first: Int + $last: Int + ) { + tenants(after: $after, before: $before, first: $first, last: $last) { + edges { + node { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + createdAt + deletedAt + isOperator + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + `, + variables: args + }) + return response.data.tenants +} + +export const createTenant = async ( + request: Request, + args: CreateTenantInput +) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.mutate< + CreateTenantMutation, + CreateTenantMutationVariables + >({ + mutation: gql` + mutation CreateTenantMutation($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + publicName + email + apiSecret + idpConsentUrl + idpSecret + } + } + } + `, + variables: { + input: args + } + }) + + return response.data?.createTenant +} + +export const updateTenant = async ( + request: Request, + args: UpdateTenantInput +) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.mutate< + UpdateTenantMutation, + UpdateTenantMutationVariables + >({ + mutation: gql` + mutation UpdateTenantMutation($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: args + } + }) + + return response.data?.updateTenant +} + +export const deleteTenant = async (request: Request, args: string) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.mutate< + DeleteTenantMutation, + DeleteTenantMutationVariables + >({ + mutation: gql` + mutation DeleteTenantMutation($id: String!) { + deleteTenant(id: $id) { + success + } + } + `, + variables: { + id: args + } + }) + + return response.data?.deleteTenant +} + +export const getTenantInfo = async ( + request: Request, + args: QueryTenantArgs +) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.query< + GetTenantQuery, + GetTenantQueryVariables + >({ + query: gql` + query GetTenantQuery($id: String!) { + tenant(id: $id) { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + createdAt + deletedAt + isOperator + } + } + `, + variables: args + }) + return response.data.tenant +} + +export const whoAmI = async (request: Request) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.query({ + query: gql` + query WhoAmIQuery { + whoami { + id + isOperator + } + } + ` + }) + return response.data.whoami +} diff --git a/packages/frontend/app/lib/validate.server.ts b/packages/frontend/app/lib/validate.server.ts index ca74197fe3..d15668419f 100644 --- a/packages/frontend/app/lib/validate.server.ts +++ b/packages/frontend/app/lib/validate.server.ts @@ -94,7 +94,7 @@ export const createAssetSchema = z .object({ code: z .string() - .min(3, { message: 'Code should be atleast 3 characters long' }) + .min(3, { message: 'Code should be at least 3 characters long' }) .max(6, { message: 'Maximum length of Code is 6 characters' }) .regex(/^[a-zA-Z]+$/, { message: 'Code should only contain letters.' }) .transform((code) => code.toUpperCase()), @@ -127,3 +127,30 @@ export const updateWalletAddressSchema = z status: z.enum([WalletAddressStatus.Active, WalletAddressStatus.Inactive]) }) .merge(uuidSchema) + +export const updateTenantSchema = z + .object({ + apiSecret: z + .string() + .min(10, { message: 'API Secret should be at least 3 characters long' }) + .max(255, { message: 'Maximum length of API Secret is 255 characters' }) + .regex( + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, + { message: 'API Secret should be Base64 encoded.' } + ), + publicName: z.string().optional(), + email: z + .string() + .regex(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, { + message: 'Invalid email address.' + }) + .optional(), + idpConsentUrl: z.string().optional(), + idpSecret: z.string().optional() + }) + .merge(uuidSchema) + +export const createTenantSchema = z + .object({}) + .merge(updateTenantSchema) + .omit({ id: true }) diff --git a/packages/frontend/app/routes/tenants.$tenantId.tsx b/packages/frontend/app/routes/tenants.$tenantId.tsx new file mode 100644 index 0000000000..cb96a5efb6 --- /dev/null +++ b/packages/frontend/app/routes/tenants.$tenantId.tsx @@ -0,0 +1,333 @@ +import { + json, + type ActionFunctionArgs, + type LoaderFunctionArgs +} from '@remix-run/node' +import { + Form, + Outlet, + useActionData, + useLoaderData, + useNavigation, + useSubmit +} from '@remix-run/react' +import { type FormEvent, useState, useRef } from 'react' +import { z } from 'zod' +import { DangerZone, PageHeader } from '~/components' +import { Button, ErrorPanel, Input, PasswordInput } from '~/components/ui' +import { + ConfirmationDialog, + type ConfirmationDialogRef +} from '~/components/ConfirmationDialog' +import { updateTenant, deleteTenant, whoAmI } from '~/lib/api/tenant.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' +import { updateTenantSchema, uuidSchema } from '~/lib/validate.server' +import type { ZodFieldErrors } from '~/shared/types' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' +import { getTenantInfo } from '~/lib/api/tenant.server' + +export async function loader({ request, params }: LoaderFunctionArgs) { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies) + + const tenantId = params.tenantId + + const result = z.string().uuid().safeParse(tenantId) + if (!result.success) { + throw json(null, { status: 400, statusText: 'Invalid tenant ID.' }) + } + + const tenant = await getTenantInfo(request, { id: result.data }) + if (!tenant) + throw json(null, { status: 404, statusText: 'Tenant not found.' }) + + const me = await whoAmI(request) + const isOperator = me.isOperator + + return json({ tenant, isOperator }) +} + +export default function ViewTenantPage() { + const { tenant, isOperator } = useLoaderData() + const response = useActionData() + const navigation = useNavigation() + const [formData, setFormData] = useState() + + const submit = useSubmit() + const dialogRef = useRef(null) + + const isSubmitting = navigation.state === 'submitting' + + const submitHandler = (event: FormEvent) => { + event.preventDefault() + setFormData(new FormData(event.currentTarget)) + dialogRef.current?.display() + } + + const onConfirm = () => { + if (formData) { + submit(formData, { method: 'post' }) + } + } + + return ( +
+
+ + + +
+
+

General Information

+

+ Created at {new Date(tenant.createdAt).toLocaleString()} +

+ +
+
+
+
+
+ + + + + +
+
+ +
+
+
+
+
+ {/* Sensitive Info */} +
+
+

Sensitive Information

+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+ {/* Sensitive - END */} + {/* Identity Provider Information */} +
+
+

+ Identity Provider Information +

+ +
+
+
+
+
+ + + +
+
+ +
+
+
+
+
+ {/* Identity Provider Information - END */} + + {/* DELETE TENANT - Danger zone */} + +
+ + + +
+
+
+ + +
+ ) +} + +export async function action({ request }: ActionFunctionArgs) { + const actionResponse: { + errors: { + general: { + fieldErrors: ZodFieldErrors + message: string[] + } + } + } = { + errors: { + general: { + fieldErrors: {}, + message: [] + } + } + } + + const session = await messageStorage.getSession(request.headers.get('cookie')) + const formData = await request.formData() + const intent = formData.get('intent') + formData.delete('intent') + + switch (intent) { + case 'general': + case 'ip': + case 'sensitive': { + const formEntries = Object.fromEntries(formData) + const result = updateTenantSchema.safeParse(formEntries) + if (!result.success) { + actionResponse.errors.general.fieldErrors = + result.error.flatten().fieldErrors + return json({ ...actionResponse }, { status: 400 }) + } + + const response = await updateTenant(request, { + ...result.data + }) + + if (!response?.tenant) { + actionResponse.errors.general.message = [ + 'Could not update tenant. Please try again!' + ] + return json({ ...actionResponse }, { status: 400 }) + } + + const me = await whoAmI(request) + // We update the apiSecret of the session in case it changed. + if (formEntries.apiSecret && me.id === formEntries.id) { + session.set('apiSecret', formEntries.apiSecret) + } + break + } + case 'delete': { + const result = uuidSchema.safeParse(Object.fromEntries(formData)) + if (!result.success) { + return setMessageAndRedirect({ + session, + message: { + content: 'Invalid tenant ID.', + type: 'error' + }, + location: '.' + }) + } + + const response = await deleteTenant(request, result.data.id) + if (!response) { + return setMessageAndRedirect({ + session, + message: { + content: 'Could not delete Tenant.', + type: 'error' + }, + location: '.' + }) + } + + return setMessageAndRedirect({ + session, + message: { + content: 'Tenant was deleted.', + type: 'success' + }, + location: '/tenants' + }) + } + default: + throw json(null, { status: 400, statusText: 'Invalid intent.' }) + } + + return setMessageAndRedirect({ + session, + message: { + content: 'Tenant information was updated', + type: 'success' + }, + location: '.' + }) +} diff --git a/packages/frontend/app/routes/tenants._index.tsx b/packages/frontend/app/routes/tenants._index.tsx new file mode 100644 index 0000000000..46397efc10 --- /dev/null +++ b/packages/frontend/app/routes/tenants._index.tsx @@ -0,0 +1,166 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData, useNavigate } from '@remix-run/react' +import { Badge, BadgeColor, PageHeader } from '~/components' +import { Button, Table } from '~/components/ui' +import { getTenantInfo, listTenants, whoAmI } from '~/lib/api/tenant.server' +import { paginationSchema } from '~/lib/validate.server' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies) + + const url = new URL(request.url) + const pagination = paginationSchema.safeParse( + Object.fromEntries(url.searchParams.entries()) + ) + + if (!pagination.success) { + throw json(null, { status: 400, statusText: 'Invalid pagination.' }) + } + + const me = await whoAmI(request) + const isOperator = me.isOperator + const tenants = isOperator + ? await listTenants(request, { + ...pagination.data + }) + : undefined + + let previousPageUrl = '', + nextPageUrl = '' + let tenantPageInfo + let tenantEdges + if (tenants) { + if (tenants.pageInfo.hasPreviousPage) + previousPageUrl = `/tenants?before=${tenants.pageInfo.startCursor}` + if (tenants.pageInfo.hasNextPage) + nextPageUrl = `/tenants?after=${tenants.pageInfo.endCursor}` + tenantPageInfo = tenants.pageInfo + tenantEdges = tenants.edges + } else { + const tenantInfo = await getTenantInfo(request, { id: me.id }) + tenantPageInfo = { hasNextPage: false, hasPreviousPage: false } + tenantEdges = [{ node: tenantInfo }] + } + + return json({ + tenantEdges, + tenantPageInfo, + previousPageUrl, + nextPageUrl, + isOperator + }) +} + +export default function TenantsPage() { + const { + tenantEdges, + tenantPageInfo, + previousPageUrl, + nextPageUrl, + isOperator + } = useLoaderData() + const navigate = useNavigate() + + return ( +
+
+ +
+

Tenants

+
+
+ {isOperator && ( + + )} +
+
+ + + + {tenantEdges.length ? ( + tenantEdges.map((tenant) => ( + + tenant.node.deletedAt + ? 'return' + : navigate(`/tenants/${tenant.node.id}`) + } + > + +
+
+ + {tenant.node.publicName ? ( + + {tenant.node.publicName} + + ) : ( + + No public name + + )} + + {tenant.node.isOperator && ( + Operator + )} +
+
+ (ID: {tenant.node.id}) +
+
+
+ + {tenant.node.email ? ( + {tenant.node.email} + ) : ( + No email + )} + + + {tenant.node.deletedAt ? ( + Inactive + ) : ( + Active + )} + +
+ )) + ) : ( + + + No tenants found. + + + )} +
+
+
+ + +
+
+
+ ) +} diff --git a/packages/frontend/app/routes/tenants.create.tsx b/packages/frontend/app/routes/tenants.create.tsx new file mode 100644 index 0000000000..e579be7ccf --- /dev/null +++ b/packages/frontend/app/routes/tenants.create.tsx @@ -0,0 +1,152 @@ +import { json, type ActionFunctionArgs } from '@remix-run/node' +import { Form, useActionData, useNavigation } from '@remix-run/react' +import { PageHeader } from '~/components' +import { Button, ErrorPanel, Input, PasswordInput } from '~/components/ui' +import { createTenant } from '~/lib/api/tenant.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' +import { createTenantSchema } from '~/lib/validate.server' +import type { ZodFieldErrors } from '~/shared/types' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' +import { type LoaderFunctionArgs } from '@remix-run/node' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies) + return null +} + +export default function CreateTenantPage() { + const response = useActionData() + const { state } = useNavigation() + const isSubmitting = state === 'submitting' + + return ( +
+
+ +

Create Tenant

+ +
+ {/* Create Tenant form */} +
+
+ +
+ +
+ {/* Tenant General Info */} +
+
+

General Information

+
+
+
+ + +
+
+
+ {/* Tenant General Info - END */} + {/* Tenant Sensitive Info */} +
+
+

Sensitive Information

+
+
+
+ +
+
+
+ {/* Tenant Sensitive Info - END */} + {/* Tenant Identity Provider */} +
+
+

+ Identity Provider Information +

+
+
+
+ + +
+
+
+ {/* Tenant Identity Provider - End */} +
+ +
+
+
+ {/* Create Tenant form - END */} +
+
+ ) +} + +export async function action({ request }: ActionFunctionArgs) { + const errors: { + fieldErrors: ZodFieldErrors + message: string[] + } = { + fieldErrors: {}, + message: [] + } + + const formData = Object.fromEntries(await request.formData()) + const result = createTenantSchema.safeParse(formData) + + if (!result.success) { + errors.fieldErrors = result.error.flatten().fieldErrors + return json({ errors }, { status: 400 }) + } + + const response = await createTenant(request, { ...result.data }) + + if (!response?.tenant) { + errors.message = ['Could not create tenant. Please try again!'] + return json({ errors }, { status: 400 }) + } + + const session = await messageStorage.getSession(request.headers.get('cookie')) + + return setMessageAndRedirect({ + session, + message: { + content: 'Tenant created.', + type: 'success' + }, + location: `/tenants/${response.tenant?.id}` + }) +} diff --git a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx index b2d69907f8..77339ff04f 100644 --- a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx +++ b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx @@ -43,7 +43,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ walletAddress }) } -export default function ViewAssetPage() { +export default function ViewWalletAddressPage() { const { walletAddress } = useLoaderData() const response = useActionData() const navigation = useNavigation() @@ -142,7 +142,7 @@ export default function ViewAssetPage() {

Withdrawal threshold

{walletAddress.asset.withdrawalThreshold ?? - 'No withdrawal threshhold'} + 'No withdrawal threshold'}

diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 5296c25729..31b6c2cff5 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -1454,6 +1454,8 @@ export type Tenant = Model & { idpConsentUrl?: Maybe; /** Secret used to secure requests from the tenant's identity provider. */ idpSecret?: Maybe; + /** Is the tenant an Operator tenant. */ + isOperator: Scalars['Boolean']['output']; /** Public name for the tenant. */ publicName?: Maybe; }; @@ -2544,6 +2546,7 @@ export type TenantResolvers; idpConsentUrl?: Resolver, ParentType, ContextType>; idpSecret?: Resolver, ParentType, ContextType>; + isOperator?: Resolver; publicName?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/test/integration/lib/generated/graphql.ts b/test/integration/lib/generated/graphql.ts index 5296c25729..31b6c2cff5 100644 --- a/test/integration/lib/generated/graphql.ts +++ b/test/integration/lib/generated/graphql.ts @@ -1454,6 +1454,8 @@ export type Tenant = Model & { idpConsentUrl?: Maybe; /** Secret used to secure requests from the tenant's identity provider. */ idpSecret?: Maybe; + /** Is the tenant an Operator tenant. */ + isOperator: Scalars['Boolean']['output']; /** Public name for the tenant. */ publicName?: Maybe; }; @@ -2544,6 +2546,7 @@ export type TenantResolvers; idpConsentUrl?: Resolver, ParentType, ContextType>; idpSecret?: Resolver, ParentType, ContextType>; + isOperator?: Resolver; publicName?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; };