diff --git a/localenv/admin-auth/docker-compose.yml b/localenv/admin-auth/docker-compose.yml index 40a35bf4fd..f53d1e7f40 100644 --- a/localenv/admin-auth/docker-compose.yml +++ b/localenv/admin-auth/docker-compose.yml @@ -1,6 +1,7 @@ services: cloud-nine-backend: environment: + KRATOS_PUBLIC_URL: 'http://cloud-nine-kratos:4433' KRATOS_ADMIN_URL: http://cloud-nine-kratos:4434/admin KRATOS_ADMIN_EMAIL: admin@mail.com depends_on: @@ -17,6 +18,7 @@ services: happy-life-backend: environment: + KRATOS_PUBLIC_URL: 'http://happy-life-kratos:4433' KRATOS_ADMIN_URL: 'http://happy-life-kratos:4434/admin' KRATOS_ADMIN_EMAIL: 'admin@mail.com' depends_on: diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index cb2eb8b04b..d94136df73 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -77,6 +77,8 @@ services: ENABLE_TELEMETRY: true KEY_ID: 7097F83B-CB84-469E-96C6-2141C72E22C0 AUTH_ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_IDP_CONSENT_URL: http://localhost:3030/mock-idp/ + OPERATOR_IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= depends_on: - shared-database - shared-redis diff --git a/localenv/cloud-nine-wallet/seed.yml b/localenv/cloud-nine-wallet/seed.yml index 81d058e509..5477a47416 100644 --- a/localenv/cloud-nine-wallet/seed.yml +++ b/localenv/cloud-nine-wallet/seed.yml @@ -1,5 +1,5 @@ tenants: - - name: PrimaryTenant + - name: c9PrimaryTenant idpConsentUrl: https://interledger.org/consent idpSecret: myVerySecureSecret endpoints: [ diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 489657021e..16794e479b 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -67,7 +67,10 @@ services: WALLET_ADDRESS_URL: ${HAPPY_LIFE_BANK_WALLET_ADDRESS_URL:-https://happy-life-bank-backend/.well-known/pay} ENABLE_TELEMETRY: true KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23d - AUTH_ADMIN_URL: http://happy-life-bank-auth:4003 + AUTH_ADMIN_URL: http://happy-life-bank-auth:3003/graphql + AUTH_ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_IDP_CONSENT_URL: http://localhost:3031/mock-idp/ + OPERATOR_IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= depends_on: - cloud-nine-backend happy-life-auth: diff --git a/localenv/happy-life-bank/seed.yml b/localenv/happy-life-bank/seed.yml index 1a8e57cff7..daccbfb3bf 100644 --- a/localenv/happy-life-bank/seed.yml +++ b/localenv/happy-life-bank/seed.yml @@ -1,3 +1,17 @@ +tenants: + - name: hlbPrimaryTenant + idpConsentUrl: https://interledger.org/consent + idpSecret: myVerySecureSecret + endpoints: [ + { + "type": "RatesUrl", + "value": "https://interledger.org/rates" + }, + { + "type": "WebhookBaseUrl", + "value": "https://interledger.org/webhooks" + } + ] assets: - code: USD scale: 2 diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index d2f1d62ed5..dea9da36e2 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -345,10 +345,12 @@ export type CreateTenantEndpointsInput = { }; export type CreateTenantInput = { + /** Email of the tenant */ + email: Scalars['String']['input']; /** List of endpoints types for the tenant */ endpoints: Array; /** IDP Endpoint */ - idpConsentEndpoint: Scalars['String']['input']; + idpConsentUrl: Scalars['String']['input']; /** IDP Secret */ idpSecret: Scalars['String']['input']; }; @@ -1309,6 +1311,8 @@ export type Tenant = Model & { __typename?: 'Tenant'; /** Date-time of creation */ createdAt: Scalars['String']['output']; + /** Tenant Email for Kratos identity & recovery */ + email: Scalars['String']['output']; /** List of tenant endpoints associated with this tenant */ endpoints: Array; /** Tenant ID that is used in subsequent resources */ @@ -2349,6 +2353,7 @@ export type SetFeeResponseResolvers = { createdAt?: Resolver; + email?: Resolver; endpoints?: Resolver, ParentType, ContextType>; id?: Resolver; kratosIdentityId?: Resolver; diff --git a/packages/auth/src/graphql/resolvers/tenant.ts b/packages/auth/src/graphql/resolvers/tenant.ts index d027c27d53..5e82349fbf 100644 --- a/packages/auth/src/graphql/resolvers/tenant.ts +++ b/packages/auth/src/graphql/resolvers/tenant.ts @@ -36,7 +36,6 @@ export const deleteTenant: MutationResolvers['deleteTenant'] = ctx ): Promise => { const tenantService = await ctx.container.use('tenantService') - const tenant = await tenantService.delete(args.input.tenantId) if (!tenant) { throw new GraphQLError(errorToMessage[TenantError.UnknownTenant], { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index c0fccfb93f..73e355cf81 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -105,6 +105,18 @@ export function initIocContainer( } ) + container.singleton( + 'tenantService', + async (deps: IocContract) => { + const [logger, knex] = await Promise.all([ + deps.use('logger'), + deps.use('knex') + ]) + + return createTenantService({ logger, knex }) + } + ) + container.singleton( 'accessService', async (deps: IocContract) => { diff --git a/packages/backend/jest.config.js b/packages/backend/jest.config.js index e6527265b5..d15f919715 100644 --- a/packages/backend/jest.config.js +++ b/packages/backend/jest.config.js @@ -18,6 +18,12 @@ process.env.USE_TIGERBEETLE = false process.env.ENABLE_TELEMETRY = false process.env.KRATOS_ADMIN_URL = 'http://127.0.0.1:4434/admin' process.env.KRATOS_ADMIN_EMAIL = 'admin@mail.com' +process.env.OPERATOR_IDP_SECRET = 'testsecret' +process.env.OPERATOR_IDP_CONSENT_URL = 'http://127.0.0.1:3030/mock-idp/' +process.env.AUTH_ADMIN_URL = 'http://127.0.0.1:3003/graphql' +process.env.AUTH_ADMIN_API_SECRET = + 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=' +process.env.KRATOS_PUBLIC_URL = 'http://127.0.0.1:4433/graphql' module.exports = { ...baseConfig, diff --git a/packages/backend/migrations/20240827115808_create_tenants_tables.js b/packages/backend/migrations/20240827115808_create_tenants_tables.js index cfa2582e1d..38a2929641 100644 --- a/packages/backend/migrations/20240827115808_create_tenants_tables.js +++ b/packages/backend/migrations/20240827115808_create_tenants_tables.js @@ -6,6 +6,7 @@ exports.up = function (knex) { return knex.schema.createTable('tenants', function (table) { table.uuid('id').primary() table.string('kratosIdentityId').notNullable() + table.string('email').notNullable() table.timestamp('createdAt').defaultTo(knex.fn.now()) table.timestamp('updatedAt').defaultTo(knex.fn.now()) table.timestamp('deletedAt').nullable().defaultTo(null) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e776b10b4b..5989036fb6 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,7 +1,6 @@ import { join } from 'path' import http, { Server } from 'http' import { ParsedUrlQuery } from 'querystring' -import axios from 'axios' import { Client as TigerbeetleClient } from 'tigerbeetle-node' import { IocContract } from '@adonisjs/fold' @@ -86,7 +85,10 @@ import { IlpPaymentService } from './payment-method/ilp/service' import { TelemetryService } from './telemetry/service' import { ApolloArmor } from '@escape.tech/graphql-armor' import { openPaymentsServerErrorMiddleware } from './open_payments/route-errors' -import { verifyApiSignature } from './shared/utils' +import { + // getTenantIdFromRequestHeaders, + verifyApiSignature +} from './shared/utils' import { WalletAddress } from './open_payments/wallet_address/model' import { getWalletAddressUrlFromIncomingPayment, @@ -102,6 +104,7 @@ import { LoggingPlugin } from './graphql/plugin' import { GrantService } from './open_payments/grant/service' import { AuthServerService } from './open_payments/authServer/service' import { TenantService } from './tenant/service' +import { EndpointType } from './tenant/endpoints/model' export interface AppContextData { logger: Logger container: AppContainer @@ -122,7 +125,12 @@ export type AppRequest = Omit< params: Record } -export interface WalletAddressUrlContext extends AppContext { +export interface TenantedAppContext extends AppContext { + tenantId: string + isOperator: boolean +} + +export interface WalletAddressUrlContext extends TenantedAppContext { walletAddressUrl: string grant?: Grant client?: string @@ -300,93 +308,28 @@ export class App { } } + // TODO: Move into Kratos service public async createOperatorIdentity(): Promise { const { kratosAdminEmail, kratosAdminUrl } = await this.container.use('config') - const logger = await this.container.use('logger') - // TODO: error out since kratos is essentially required if (!kratosAdminUrl || !kratosAdminEmail) { throw new Error('Missing admin configuration') } - try { - const identityQueryResponse = await axios.get( - `${kratosAdminUrl}/identities?credentials_identifier=${kratosAdminEmail}` - ) - const isExistingIdentity = - identityQueryResponse.data.length > 0 && - identityQueryResponse.data[0].id - const operatorRole = - identityQueryResponse.data[0]?.metadata_admin.operator - let identityResponse - if (isExistingIdentity && operatorRole) { - // Identity already exists with operator role - logger.debug( - `Identity with email ${kratosAdminEmail} exists on the system with the ID: ${identityQueryResponse.data[0].id}` - ) - return - } else if (isExistingIdentity && !operatorRole) { - // Identity already exists but does not have operator role - identityResponse = await axios.put( - `${kratosAdminUrl}/admin/identities/${identityQueryResponse.data[0].id}`, - { - metadata_admin: { - operator: true - } - } - ) - logger.debug( - `Successfully created user ${kratosAdminEmail} with ID ${identityResponse.data.id}` - ) - } else { - // Identity does not exist - logger.debug( - `No identity with email ${kratosAdminEmail} exists on the system` - ) - - identityResponse = await axios.post( - `${kratosAdminUrl}/identities`, - { - schema_id: 'default', - traits: { - email: kratosAdminEmail - }, - metadata_admin: { - operator: true - } - }, - { - headers: { - 'Content-Type': 'application/json' - } - } - ) - logger.debug( - `Successfully created user ${kratosAdminEmail} with ID ${identityResponse.data.id}` - ) - } - - const recoveryCodeResponse = await axios.post( - `${kratosAdminUrl}/recovery/link`, - { - identity_id: identityResponse.data.id - } - ) - logger.info( - `Recovery link for ${kratosAdminEmail} at ${recoveryCodeResponse.data.recovery_link}` - ) - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error( - `Error retrieving identity ${kratosAdminEmail}:`, - error.response?.status, - error.response?.data - ) - } else { - logger.error( - `An unexpected error occurred while trying to retrieve the identity for ${kratosAdminEmail}:`, - error - ) - } + // Create tenant if it does not exist + // TODO: check if tenant exists by querying psql for tenant email, then querying kratos for stored kratosId if found + const tenantService = await this.container.use('tenantService') + const operatorTenant = await tenantService.getByEmail(kratosAdminEmail) + if (!operatorTenant) { + const config = await this.container.use('config') + await tenantService.create({ + idpSecret: config.operatorIdpSecret, + idpConsentEndpoint: config.operatorIdpConsentUrl, + endpoints: [ + { value: config.webhookUrl, type: EndpointType.WebhookBaseUrl } + ], + email: config.kratosAdminEmail, + isOperator: true + }) } } @@ -483,6 +426,13 @@ export class App { }) } + // Determine Kratos Identity + // TODO: Comment out for now until seed script has a good way to acquire a kratos session + // koa.use(async (ctx: TenantedAppContext, next: Koa.Next): Promise => { + // await getTenantIdFromRequestHeaders(ctx, this.config) + // return next() + // }) + koa.use( koaMiddleware(this.apolloServer, { context: async (): Promise => { diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 60a1c1bcd5..012b0aa4d1 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -191,8 +191,11 @@ export const Config = { 2592000000 ), // 30 days enableSpspPaymentPointers: envBool('ENABLE_SPSP_PAYMENT_POINTERS', true), + kratosPublicUrl: envString('KRATOS_PUBLIC_URL'), kratosAdminUrl: envString('KRATOS_ADMIN_URL'), - kratosAdminEmail: envString('KRATOS_ADMIN_EMAIL') + kratosAdminEmail: envString('KRATOS_ADMIN_EMAIL'), + operatorIdpSecret: envString('OPERATOR_IDP_SECRET'), + operatorIdpConsentUrl: envString('OPERATOR_IDP_CONSENT_URL') } function parseRedisTlsConfig( diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 447f462915..256fd74825 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -2174,6 +2174,22 @@ "description": null, "fields": null, "inputFields": [ + { + "name": "email", + "description": "Email of the tenant", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "endpoints", "description": "List of endpoints types for the tenant", @@ -2199,7 +2215,7 @@ "deprecationReason": null }, { - "name": "idpConsentEndpoint", + "name": "idpConsentUrl", "description": "IDP Endpoint", "type": { "kind": "NON_NULL", @@ -7810,6 +7826,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "email", + "description": "Tenant Email for Kratos identity & recovery", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "endpoints", "description": "List of tenant endpoints associated with this tenant", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index d2f1d62ed5..dea9da36e2 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -345,10 +345,12 @@ export type CreateTenantEndpointsInput = { }; export type CreateTenantInput = { + /** Email of the tenant */ + email: Scalars['String']['input']; /** List of endpoints types for the tenant */ endpoints: Array; /** IDP Endpoint */ - idpConsentEndpoint: Scalars['String']['input']; + idpConsentUrl: Scalars['String']['input']; /** IDP Secret */ idpSecret: Scalars['String']['input']; }; @@ -1309,6 +1311,8 @@ export type Tenant = Model & { __typename?: 'Tenant'; /** Date-time of creation */ createdAt: Scalars['String']['output']; + /** Tenant Email for Kratos identity & recovery */ + email: Scalars['String']['output']; /** List of tenant endpoints associated with this tenant */ endpoints: Array; /** Tenant ID that is used in subsequent resources */ @@ -2349,6 +2353,7 @@ export type SetFeeResponseResolvers = { createdAt?: Resolver; + email?: Resolver; endpoints?: Resolver, ParentType, ContextType>; id?: Resolver; kratosIdentityId?: Resolver; diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index e6f05f04ae..fa27b2224b 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -68,7 +68,6 @@ import { getCombinedPayments } from './combined_payments' import { createOrUpdatePeerByUrl } from './auto-peering' import { getAccountingTransfers } from './accounting_transfer' import { createTenant, getTenant, getTenants } from './tenant' -import { getTenantEndpoints } from './tenant_endpoints' export const resolvers: Resolvers = { UInt8: GraphQLUInt8, diff --git a/packages/backend/src/graphql/resolvers/tenant.ts b/packages/backend/src/graphql/resolvers/tenant.ts index 9d023ceda9..c05353051b 100644 --- a/packages/backend/src/graphql/resolvers/tenant.ts +++ b/packages/backend/src/graphql/resolvers/tenant.ts @@ -16,7 +16,7 @@ import { import { Tenant } from '../../tenant/model' import { Pagination, SortOrder } from '../../shared/baseModel' import { getPageInfo } from '../../shared/pagination' -import { EndpointType, TenantEndpoint } from '../../tenant/endpoints/model' +import { EndpointType } from '../../tenant/endpoints/model' import { tenantEndpointToGraphql } from './tenant_endpoints' const mapTenantEndpointTypeToModelEndpointType = { @@ -77,14 +77,15 @@ export const createTenant: MutationResolvers['createTenant'] = const tenantService = await ctx.container.use('tenantService') const tenantOrError = await tenantService.create({ - idpConsentEndpoint: args.input.idpConsentEndpoint, + idpConsentEndpoint: args.input.idpConsentUrl, idpSecret: args.input.idpSecret, endpoints: args.input.endpoints.map((endpoint) => { return { value: endpoint.value, type: mapTenantEndpointTypeToModelEndpointType[endpoint.type] } - }) + }), + email: args.input.email }) if (isTenantError(tenantOrError)) { @@ -103,10 +104,11 @@ export const createTenant: MutationResolvers['createTenant'] = export function tenantToGraphql(tenant: Tenant): SchemaTenant { return { id: tenant.id, + email: tenant.email, kratosIdentityId: tenant.kratosIdentityId, //we should probably paginate this, but for now, that we only have like two endpoints it should be ok endpoints: tenant.endpoints.map(tenantEndpointToGraphql), - createdAt: new Date(tenant.createdAt).toISOString(), - updatedAt: new Date(tenant.updatedAt).toISOString() + createdAt: tenant.createdAt.toISOString(), + updatedAt: tenant.updatedAt.toISOString() } } diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index eb90571157..4a313ae595 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1158,11 +1158,14 @@ input CreateTenantEndpointsInput { } input CreateTenantInput { + "Email of the tenant" + email: String! + "List of endpoints types for the tenant" endpoints: [CreateTenantEndpointsInput!]! "IDP Endpoint" - idpConsentEndpoint: String! + idpConsentUrl: String! "IDP Secret" idpSecret: String! @@ -1389,6 +1392,8 @@ type TenantEndpoint { type Tenant implements Model { "Tenant ID that is used in subsequent resources" id: ID! + "Tenant Email for Kratos identity & recovery" + email: String! "Kratos identity ID" kratosIdentityId: String! "Date-time of creation" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 6d5ee20c1a..201c90a8aa 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -535,8 +535,9 @@ export function initIocContainer( }) container.singleton('tenantService', async (deps) => { - const [logger, knex, config, apolloClient, tenantEndpointService] = + const [axios, logger, knex, config, apolloClient, tenantEndpointService] = await Promise.all([ + deps.use('axios'), deps.use('logger'), deps.use('knex'), deps.use('config'), @@ -545,6 +546,7 @@ export function initIocContainer( ]) return createTenantService({ + axios, logger, knex, config, @@ -755,6 +757,7 @@ export const start = async ( } await app.createOperatorIdentity() + logger.info('Operator identity created on Kratos') } // If this script is run directly, start the server diff --git a/packages/backend/src/shared/baseModel.ts b/packages/backend/src/shared/baseModel.ts index 015ce323bb..d9311ac07e 100644 --- a/packages/backend/src/shared/baseModel.ts +++ b/packages/backend/src/shared/baseModel.ts @@ -138,7 +138,6 @@ export abstract class WeakModel extends PaginationModel { export abstract class BaseModel extends WeakModel { public id!: string - public $beforeInsert(context: QueryContext): void { super.$beforeInsert(context) this.id = this.id || uuid() diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index 172af2ab77..07610853a1 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -2,8 +2,9 @@ import { validate, version } from 'uuid' import { URL, type URL as URLType } from 'url' import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' +import axios from 'axios' import { IAppConfig } from '../config/app' -import { AppContext } from '../app' +import { AppContext, TenantedAppContext } from '../app' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -201,3 +202,30 @@ export async function verifyApiSignature( return verifyApiSignatureDigest(signature as string, ctx.request, config) } + +export async function getTenantIdFromRequestHeaders( + ctx: TenantedAppContext, + config: IAppConfig +): Promise { + const cookie = ctx.request.headers['cookie'] + const session = await axios.get(`${config.kratosPublicUrl}/sessions/whoami`, { + headers: { + cookie + }, + withCredentials: true + }) + + if (session.status !== 200 || !session.data?.active) { + ctx.throw(401, 'Unauthorized') + } + + const identityId = session.data?.identity.id + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.getByIdentity(identityId) + if (!tenant) { + ctx.throw(401, 'Unauthorized') + } + + ctx.tenantId = tenant.id + ctx.isOperator = session.data?.identity.metadata_public.operator +} diff --git a/packages/backend/src/tenant/endpoints/service.ts b/packages/backend/src/tenant/endpoints/service.ts index 83c314e761..ac4a65f041 100644 --- a/packages/backend/src/tenant/endpoints/service.ts +++ b/packages/backend/src/tenant/endpoints/service.ts @@ -1,6 +1,5 @@ import { TransactionOrKnex } from 'objection' import { BaseService } from '../../shared/baseService' -import { TenantEndpointError } from './errors' import { EndpointType, TenantEndpoint } from './model' import { Pagination, SortOrder } from '../../shared/baseModel' @@ -16,9 +15,7 @@ export interface CreateOptions { } export interface TenantEndpointService { - create( - createOptions: CreateOptions - ): Promise + create(createOptions: CreateOptions): Promise getForTenant(tenantId: string): Promise getPage( pagination?: Pagination, @@ -58,18 +55,18 @@ async function getTenantEndpointsPage( pagination?: Pagination, sortOrder?: SortOrder ) { - console.log('GET TENANT ENDPOINTS PAGE') - const data = await TenantEndpoint.query(deps.knex) - .returning(['type', 'value', 'createdAt', 'updatedAt']) - .getPage(pagination, sortOrder) - console.log('DATA: ', data) - return data + console.log('GET TENANT ENDPOINTS PAGE') + const data = await TenantEndpoint.query(deps.knex) + .returning(['type', 'value', 'createdAt', 'updatedAt']) + .getPage(pagination, sortOrder) + console.log('DATA: ', data) + return data } async function createTenantEndpoint( deps: ServiceDependencies, createOptions: CreateOptions -): Promise { +): Promise { const tenantEndpointsData = createOptions.endpoints.map((endpoint) => ({ type: endpoint.type, value: endpoint.value, diff --git a/packages/backend/src/tenant/model.ts b/packages/backend/src/tenant/model.ts index 6f86ec70f9..4e522f0251 100644 --- a/packages/backend/src/tenant/model.ts +++ b/packages/backend/src/tenant/model.ts @@ -1,5 +1,5 @@ import { Model } from 'objection' -import { BaseModel, WeakModel } from '../shared/baseModel' +import { BaseModel } from '../shared/baseModel' import { TenantEndpoint } from './endpoints/model' export class Tenant extends BaseModel { @@ -20,7 +20,12 @@ export class Tenant extends BaseModel { } } + public email!: string public kratosIdentityId!: string public deletedAt?: Date public endpoints!: TenantEndpoint[] } + +export interface TenantWithEndpoints extends Tenant { + endpoints: NonNullable +} diff --git a/packages/backend/src/tenant/service.ts b/packages/backend/src/tenant/service.ts index 24e9f1bfb8..8ad3b2375f 100644 --- a/packages/backend/src/tenant/service.ts +++ b/packages/backend/src/tenant/service.ts @@ -1,31 +1,35 @@ +import { AxiosInstance } from 'axios' import { TransactionOrKnex } from 'objection' import { BaseService } from '../shared/baseService' import { TenantError } from './errors' -import { Tenant } from './model' +import { Tenant, TenantWithEndpoints } from './model' import { IAppConfig } from '../config/app' import { ApolloClient, gql, NormalizedCacheObject } from '@apollo/client' import { Tenant as AuthTenant, CreateTenantInput as CreateAuthTenantInput } from '../generated/graphql' -import { v4 as uuidv4 } from 'uuid' import { Pagination, SortOrder } from '../shared/baseModel' import { EndpointOptions, TenantEndpointService } from './endpoints/service' -import { TenantEndpoint } from './endpoints/model' export interface CreateTenantOptions { idpConsentEndpoint: string idpSecret: string endpoints: EndpointOptions[] + email: string + isOperator?: boolean } export interface TenantService { get(id: string): Promise + getByEmail(email: string): Promise + getByIdentity(id: string): Promise getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise create(createOptions: CreateTenantOptions): Promise } export interface ServiceDependencies extends BaseService { + axios: AxiosInstance knex: TransactionOrKnex config: IAppConfig apolloClient: ApolloClient @@ -36,6 +40,7 @@ export async function createTenantService( deps_: ServiceDependencies ): Promise { const deps: ServiceDependencies = { + axios: deps_.axios, logger: deps_.logger.child({ service: 'TenantService' }), @@ -47,6 +52,8 @@ export async function createTenantService( return { get: (id: string) => getTenant(deps, id), + getByEmail: (email: string) => getByEmail(deps, email), + getByIdentity: (id: string) => getByIdentity(deps, id), getPage: (pagination?, sortOrder?) => getTenantsPage(deps, pagination, sortOrder), create: (options: CreateTenantOptions) => createTenant(deps, options) @@ -59,6 +66,7 @@ async function getTenantsPage( sortOrder?: SortOrder ): Promise { return await Tenant.query(deps.knex) + .withGraphFetched('endpoints') .getPage(pagination, sortOrder) } @@ -66,15 +74,33 @@ async function getTenant( deps: ServiceDependencies, id: string ): Promise { - return Tenant.query(deps.knex) - .withGraphFetched('endpoints') - .findById(id) + return Tenant.query(deps.knex).withGraphFetched('endpoints').findById(id) +} + +// TODO: Tests for this +async function getByEmail( + deps: ServiceDependencies, + email: string +): Promise { + return Tenant.query(deps.knex).findOne({ + email + }) +} + +// TODO: tests for this service function +async function getByIdentity( + deps: ServiceDependencies, + id: string +): Promise { + return Tenant.query(deps.knex).findOne({ + kratosIdentityId: id + }) } async function createTenant( deps: ServiceDependencies, options: CreateTenantOptions -): Promise { +): Promise { /** * 1. Open DB transaction * 2. Insert tenant data into DB @@ -82,50 +108,110 @@ async function createTenant( * 3.1 if success, commit DB trx and return tenant data * 3.2 if error, rollback DB trx and return error */ - return deps.knex.transaction(async (trx) => { - let tenant: Tenant - try { - // create tenant on backend - tenant = await Tenant.query(trx).insert({ - kratosIdentityId: uuidv4() - }) - - await deps.tenantEndpointService.create({ - endpoints: options.endpoints, - tenantId: tenant.id, - trx - }) - - // call auth admin api - const mutation = gql` - mutation CreateAuthTenant($input: CreateTenantInput!) { - createTenant(input: $input) { - tenant { - id - } + let tenant: Tenant + const trx = await Tenant.startTransaction() + const { axios } = deps + try { + // TODO: move into kratos service + const { kratosAdminUrl } = deps.config + let identityId + const getIdentityResponse = await axios.get( + `${kratosAdminUrl}/identities?credentials_identifier=${options.email}` + ) + const operatorRole = getIdentityResponse.data[0]?.metadata_public.operator + const isExistingIdentity = + getIdentityResponse.data.length > 0 && getIdentityResponse.data[0].id + deps.logger.info( + { res: getIdentityResponse.data, operatorRole, isExistingIdentity }, + 'got response' + ) + if (!isExistingIdentity) { + // Identity does not exist + const createIdentityResponse = await axios.post( + `${kratosAdminUrl}/identities`, + { + schema_id: 'default', + traits: { + email: options.email + }, + metadata_public: { + operator: options.isOperator + } + }, + { + headers: { + 'Content-Type': 'application/json' } } - ` - const variables = { - input: { - tenantId: tenant.id, - idpSecret: options.idpSecret, - idpConsentEndpoint: options.idpConsentEndpoint + ) + + identityId = createIdentityResponse.data.id + } else if (!operatorRole && options.isOperator) { + // Identity already exists but does not have operator role + const updateIdentityResponse = await axios.put( + `${kratosAdminUrl}/admin/identities/${getIdentityResponse.data[0].id}`, + { + metadata_public: { + operator: true + } } + ) + identityId = updateIdentityResponse.data.id + } else { + identityId = getIdentityResponse.data[0].id + } + + const recoveryRequest = await axios.post( + `${kratosAdminUrl}/recovery/link`, + { + identity_id: identityId } + ) + deps.logger.info( + `Recovery link for ${options.email} at ${recoveryRequest.data.recovery_link}` + ) + // create tenant on backend + tenant = await Tenant.query(trx).insertAndFetch({ + email: options.email, + kratosIdentityId: identityId + }) + + const endpoints = await deps.tenantEndpointService.create({ + endpoints: options.endpoints, + tenantId: tenant.id, + trx + }) - await deps.apolloClient.mutate< - AuthTenant, - { input: CreateAuthTenantInput } - >({ - mutation, - variables - }) - } catch (err) { - await trx.rollback() - throw err + // call auth admin api + const mutation = gql` + mutation CreateAuthTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + } + } + } + ` + const variables = { + input: { + tenantId: tenant.id, + idpSecret: options.idpSecret, + idpConsentEndpoint: options.idpConsentEndpoint + } } + await deps.apolloClient.mutate< + AuthTenant, + { input: CreateAuthTenantInput } + >({ + mutation, + variables + }) + await trx.commit() + tenant.endpoints = endpoints return tenant - }) + } catch (err) { + await trx.rollback() + throw err + } } diff --git a/packages/frontend/app/components/Sidebar.tsx b/packages/frontend/app/components/Sidebar.tsx index 5b140a25ca..4e69c513c3 100644 --- a/packages/frontend/app/components/Sidebar.tsx +++ b/packages/frontend/app/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { Button } from '~/components/ui' interface SidebarProps { logoutUrl: string authEnabled: boolean + isOperator: boolean } const navigation = [ @@ -38,7 +39,11 @@ const navigation = [ } ] -export const Sidebar: FC = ({ logoutUrl, authEnabled }) => { +export const Sidebar: FC = ({ + logoutUrl, + authEnabled, + isOperator +}) => { const [sidebarIsOpen, setSidebarIsOpen] = useState(false) return ( @@ -98,6 +103,22 @@ export const Sidebar: FC = ({ logoutUrl, authEnabled }) => { {name} ))} + {authEnabled && isOperator && ( + + cx( + isActive + ? 'bg-mercury' + : 'text-tealish/70 hover:bg-mercury/70', + 'flex p-2 font-medium rounded-md' + ) + } + > + Tenants + + )} {authEnabled && ( = ({ logoutUrl, authEnabled }) => { {name} ))} + {authEnabled && isOperator && ( + + cx( + isActive + ? 'bg-mercury' + : 'text-tealish/70 hover:bg-mercury/70', + 'flex p-2 font-medium rounded-md' + ) + } + > + Tenants + + )} {authEnabled && ( ; /** IDP Endpoint */ - idpConsentEndpoint: Scalars['String']['input']; + idpConsentUrl: Scalars['String']['input']; /** IDP Secret */ idpSecret: Scalars['String']['input']; }; @@ -1309,6 +1311,8 @@ export type Tenant = Model & { __typename?: 'Tenant'; /** Date-time of creation */ createdAt: Scalars['String']['output']; + /** Tenant Email for Kratos identity & recovery */ + email: Scalars['String']['output']; /** List of tenant endpoints associated with this tenant */ endpoints: Array; /** Tenant ID that is used in subsequent resources */ @@ -2349,6 +2353,7 @@ export type SetFeeResponseResolvers = { createdAt?: Resolver; + email?: Resolver; endpoints?: Resolver, ParentType, ContextType>; id?: Resolver; kratosIdentityId?: Resolver; @@ -2736,6 +2741,30 @@ export type WithdrawPeerLiquidityVariables = Exact<{ export type WithdrawPeerLiquidity = { __typename?: 'Mutation', createPeerLiquidityWithdrawal?: { __typename?: 'LiquidityMutationResponse', success: boolean } | null }; +export type GetTenantQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type GetTenantQuery = { __typename?: 'Query', tenant?: { __typename?: 'Tenant', id: string } | 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, createdAt: string } }>, 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?: 'CreateTenantMutationResponse', tenant: { __typename?: 'Tenant', id: string } } }; + export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; }>; diff --git a/packages/frontend/app/lib/api/asset.server.ts b/packages/frontend/app/lib/api/asset.server.ts index 25935c531f..e75eab74ad 100644 --- a/packages/frontend/app/lib/api/asset.server.ts +++ b/packages/frontend/app/lib/api/asset.server.ts @@ -29,7 +29,7 @@ import type { } from '~/generated/graphql' import { apolloClient } from '../apollo.server' -export const getAssetInfo = async (args: QueryAssetArgs) => { +export const getAssetInfo = async (args: QueryAssetArgs, cookie?: string) => { const response = await apolloClient.query< GetAssetQuery, GetAssetQueryVariables @@ -51,12 +51,16 @@ export const getAssetInfo = async (args: QueryAssetArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.asset } -export const getAssetWithFees = async (args: QueryAssetArgs) => { +export const getAssetWithFees = async ( + args: QueryAssetArgs, + cookie?: string +) => { const response = await apolloClient.query< GetAssetWithFeesQuery, GetAssetWithFeesQueryVariables @@ -92,12 +96,13 @@ export const getAssetWithFees = async (args: QueryAssetArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.asset } -export const listAssets = async (args: QueryAssetsArgs) => { +export const listAssets = async (args: QueryAssetsArgs, cookie?: string) => { const response = await apolloClient.query< ListAssetsQuery, ListAssetsQueryVariables @@ -128,13 +133,14 @@ export const listAssets = async (args: QueryAssetsArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.assets } -export const createAsset = async (args: CreateAssetInput) => { +export const createAsset = async (args: CreateAssetInput, cookie?: string) => { const response = await apolloClient.mutate< CreateAssetMutation, CreateAssetMutationVariables @@ -160,13 +166,14 @@ export const createAsset = async (args: CreateAssetInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createAsset } -export const updateAsset = async (args: UpdateAssetInput) => { +export const updateAsset = async (args: UpdateAssetInput, cookie?: string) => { const response = await apolloClient.mutate< UpdateAssetMutation, UpdateAssetMutationVariables @@ -192,13 +199,14 @@ export const updateAsset = async (args: UpdateAssetInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.updateAsset } -export const setFee = async (args: SetFeeInput) => { +export const setFee = async (args: SetFeeInput, cookie?: string) => { const response = await apolloClient.mutate< SetFeeMutation, SetFeeMutationVariables @@ -219,14 +227,16 @@ export const setFee = async (args: SetFeeInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.setFee } export const depositAssetLiquidity = async ( - args: DepositAssetLiquidityInput + args: DepositAssetLiquidityInput, + cookie?: string ) => { const response = await apolloClient.mutate< DepositAssetLiquidityMutation, @@ -243,14 +253,16 @@ export const depositAssetLiquidity = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.depositAssetLiquidity } export const withdrawAssetLiquidity = async ( - args: CreateAssetLiquidityWithdrawalInput + args: CreateAssetLiquidityWithdrawalInput, + cookie?: string ) => { const response = await apolloClient.mutate< WithdrawAssetLiquidity, @@ -267,19 +279,20 @@ export const withdrawAssetLiquidity = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createAssetLiquidityWithdrawal } -export const loadAssets = async () => { +export const loadAssets = async (cookie?: string) => { 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({ first: 100, after }, cookie) if (response.edges) { assets = [...assets, ...response.edges] @@ -292,7 +305,7 @@ export const loadAssets = async () => { return assets } -export const deleteAsset = async (args: DeleteAssetInput) => { +export const deleteAsset = async (args: DeleteAssetInput, cookie?: string) => { const response = await apolloClient.mutate< DeleteAssetMutation, DeleteAssetMutationVariables @@ -318,7 +331,8 @@ export const deleteAsset = async (args: DeleteAssetInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.deleteAsset diff --git a/packages/frontend/app/lib/api/payments.server.ts b/packages/frontend/app/lib/api/payments.server.ts index 8c330d10a2..5c6b48b89f 100644 --- a/packages/frontend/app/lib/api/payments.server.ts +++ b/packages/frontend/app/lib/api/payments.server.ts @@ -23,7 +23,10 @@ import { } from '~/generated/graphql' import { apolloClient } from '../apollo.server' -export const getIncomingPayment = async (args: QueryIncomingPaymentArgs) => { +export const getIncomingPayment = async ( + args: QueryIncomingPaymentArgs, + cookie?: string +) => { await apolloClient.query const response = await apolloClient.query< GetIncomingPayment, @@ -52,12 +55,16 @@ export const getIncomingPayment = async (args: QueryIncomingPaymentArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.incomingPayment } -export const getOutgoingPayment = async (args: QueryOutgoingPaymentArgs) => { +export const getOutgoingPayment = async ( + args: QueryOutgoingPaymentArgs, + cookie?: string +) => { const response = await apolloClient.query< GetOutgoingPayment, GetOutgoingPaymentVariables @@ -91,12 +98,16 @@ export const getOutgoingPayment = async (args: QueryOutgoingPaymentArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.outgoingPayment } -export const listPayments = async (args: QueryPaymentsArgs) => { +export const listPayments = async ( + args: QueryPaymentsArgs, + cookie?: string +) => { const response = await apolloClient.query< ListPaymentsQuery, ListPaymentsQueryVariables @@ -133,14 +144,16 @@ export const listPayments = async (args: QueryPaymentsArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.payments } export const depositOutgoingPaymentLiquidity = async ( - args: DepositOutgoingPaymentLiquidityInput + args: DepositOutgoingPaymentLiquidityInput, + cookie?: string ) => { const response = await apolloClient.mutate< DepositOutgoingPaymentLiquidity, @@ -157,14 +170,16 @@ export const depositOutgoingPaymentLiquidity = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.depositOutgoingPaymentLiquidity } export const createOutgoingPaymentWithdrawal = async ( - args: CreateOutgoingPaymentWithdrawalInput + args: CreateOutgoingPaymentWithdrawalInput, + cookie?: string ) => { const response = await apolloClient.mutate< CreateOutgoingPaymentWithdrawal, @@ -181,14 +196,16 @@ export const createOutgoingPaymentWithdrawal = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createOutgoingPaymentWithdrawal } export const createIncomingPaymentWithdrawal = async ( - args: CreateIncomingPaymentWithdrawalInput + args: CreateIncomingPaymentWithdrawalInput, + cookie?: string ) => { const response = await apolloClient.mutate< CreateIncomingPaymentWithdrawal, @@ -205,7 +222,8 @@ export const createIncomingPaymentWithdrawal = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createIncomingPaymentWithdrawal diff --git a/packages/frontend/app/lib/api/peer.server.ts b/packages/frontend/app/lib/api/peer.server.ts index fd064ed0c5..a89064bd63 100644 --- a/packages/frontend/app/lib/api/peer.server.ts +++ b/packages/frontend/app/lib/api/peer.server.ts @@ -24,7 +24,7 @@ import type { } from '~/generated/graphql' import { apolloClient } from '../apollo.server' -export const getPeer = async (args: QueryPeerArgs) => { +export const getPeer = async (args: QueryPeerArgs, cookie?: string) => { const response = await apolloClient.query< GetPeerQuery, GetPeerQueryVariables @@ -53,13 +53,14 @@ export const getPeer = async (args: QueryPeerArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.peer } -export const listPeers = async (args: QueryPeersArgs) => { +export const listPeers = async (args: QueryPeersArgs, cookie?: string) => { const response = await apolloClient.query< ListPeersQuery, ListPeersQueryVariables @@ -97,13 +98,14 @@ export const listPeers = async (args: QueryPeersArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.peers } -export const createPeer = async (args: CreatePeerInput) => { +export const createPeer = async (args: CreatePeerInput, cookie?: string) => { const response = await apolloClient.mutate< CreatePeerMutation, CreatePeerMutationVariables @@ -119,13 +121,14 @@ export const createPeer = async (args: CreatePeerInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createPeer } -export const updatePeer = async (args: UpdatePeerInput) => { +export const updatePeer = async (args: UpdatePeerInput, cookie?: string) => { const response = await apolloClient.mutate< UpdatePeerMutation, UpdatePeerMutationVariables @@ -141,13 +144,17 @@ export const updatePeer = async (args: UpdatePeerInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.updatePeer } -export const deletePeer = async (args: MutationDeletePeerArgs) => { +export const deletePeer = async ( + args: MutationDeletePeerArgs, + cookie?: string +) => { const response = await apolloClient.mutate< DeletePeerMutation, DeletePeerMutationVariables @@ -159,13 +166,17 @@ export const deletePeer = async (args: MutationDeletePeerArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data?.deletePeer } -export const depositPeerLiquidity = async (args: DepositPeerLiquidityInput) => { +export const depositPeerLiquidity = async ( + args: DepositPeerLiquidityInput, + cookie?: string +) => { const response = await apolloClient.mutate< DepositPeerLiquidityMutation, DepositPeerLiquidityMutationVariables @@ -181,14 +192,16 @@ export const depositPeerLiquidity = async (args: DepositPeerLiquidityInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.depositPeerLiquidity } export const withdrawPeerLiquidity = async ( - args: CreatePeerLiquidityWithdrawalInput + args: CreatePeerLiquidityWithdrawalInput, + cookie?: string ) => { const response = await apolloClient.mutate< WithdrawPeerLiquidity, @@ -205,7 +218,8 @@ export const withdrawPeerLiquidity = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createPeerLiquidityWithdrawal 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..67cf13f486 --- /dev/null +++ b/packages/frontend/app/lib/api/tenant.server.ts @@ -0,0 +1,93 @@ +import { gql } from '@apollo/client' +import type { + GetTenantQuery, + GetTenantQueryVariables, + QueryTenantArgs, + CreateTenantInput, + CreateTenantMutation, + CreateTenantMutationVariables, + ListTenantsQuery, + ListTenantsQueryVariables, + QueryTenantsArgs +} from '~/generated/graphql' +import { apolloClient } from '../apollo.server' + +export const getTenant = async (args: QueryTenantArgs, cookie?: string) => { + const response = await apolloClient.query< + GetTenantQuery, + GetTenantQueryVariables + >({ + query: gql` + query GetTenantQuery($id: ID!) { + tenant(id: $id) { + id + } + } + `, + variables: args, + context: { headers: { cookie } } + }) + + return response.data.tenant +} + +export const listTenants = async (args: QueryTenantsArgs, cookie?: string) => { + 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 + createdAt + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + `, + variables: args, + context: { headers: { cookie } } + }) + + return response.data.tenants +} + +export const createTenant = async ( + args: CreateTenantInput, + cookie?: string +) => { + const response = await apolloClient.mutate< + CreateTenantMutation, + CreateTenantMutationVariables + >({ + mutation: gql` + mutation CreateTenantMutation($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + } + } + } + `, + variables: { + input: args + }, + context: { headers: { cookie } } + }) + + return response.data?.createTenant +} diff --git a/packages/frontend/app/lib/api/wallet-address.server.ts b/packages/frontend/app/lib/api/wallet-address.server.ts index bf3e680ca4..34a4c986e9 100644 --- a/packages/frontend/app/lib/api/wallet-address.server.ts +++ b/packages/frontend/app/lib/api/wallet-address.server.ts @@ -16,7 +16,10 @@ import type { CreateWalletAddressWithdrawalInput } from '~/generated/graphql' -export const getWalletAddress = async (args: QueryWalletAddressArgs) => { +export const getWalletAddress = async ( + args: QueryWalletAddressArgs, + cookie?: string +) => { const response = await apolloClient.query< GetWalletAddressQuery, GetWalletAddressQueryVariables @@ -39,13 +42,17 @@ export const getWalletAddress = async (args: QueryWalletAddressArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.walletAddress } -export const listWalletAddresses = async (args: QueryWalletAddressesArgs) => { +export const listWalletAddresses = async ( + args: QueryWalletAddressesArgs, + cookie?: string +) => { const response = await apolloClient.query< ListWalletAddresssQuery, ListWalletAddresssQueryVariables @@ -81,13 +88,17 @@ export const listWalletAddresses = async (args: QueryWalletAddressesArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.walletAddresses } -export const updateWalletAddress = async (args: UpdateWalletAddressInput) => { +export const updateWalletAddress = async ( + args: UpdateWalletAddressInput, + cookie?: string +) => { const response = await apolloClient.mutate({ mutation: gql` mutation UpdateWalletAddressMutation($input: UpdateWalletAddressInput!) { @@ -100,13 +111,17 @@ export const updateWalletAddress = async (args: UpdateWalletAddressInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data.updateWalletAddress } -export const createWalletAddress = async (args: CreateWalletAddressInput) => { +export const createWalletAddress = async ( + args: CreateWalletAddressInput, + cookie?: string +) => { const response = await apolloClient.mutate< CreateWalletAddressMutation, CreateWalletAddressMutationVariables @@ -122,14 +137,16 @@ export const createWalletAddress = async (args: CreateWalletAddressInput) => { `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createWalletAddress } export const createWalletAddressWithdrawal = async ( - args: CreateWalletAddressWithdrawalInput + args: CreateWalletAddressWithdrawalInput, + cookie?: string ) => { const response = await apolloClient.mutate< CreateWalletAddressWithdrawal, @@ -148,7 +165,8 @@ export const createWalletAddressWithdrawal = async ( `, variables: { input: args - } + }, + context: { headers: { cookie } } }) return response.data?.createWalletAddressWithdrawal diff --git a/packages/frontend/app/lib/api/webhook.server.ts b/packages/frontend/app/lib/api/webhook.server.ts index d88beec5ee..8461e197bc 100644 --- a/packages/frontend/app/lib/api/webhook.server.ts +++ b/packages/frontend/app/lib/api/webhook.server.ts @@ -6,7 +6,10 @@ import type { ListWebhookEventsVariables } from '~/generated/graphql' -export const listWebhooks = async (args: QueryWebhookEventsArgs) => { +export const listWebhooks = async ( + args: QueryWebhookEventsArgs, + cookie?: string +) => { const response = await apolloClient.query< ListWebhookEvents, ListWebhookEventsVariables @@ -44,7 +47,8 @@ export const listWebhooks = async (args: QueryWebhookEventsArgs) => { } } `, - variables: args + variables: args, + context: { headers: { cookie } } }) return response.data.webhookEvents diff --git a/packages/frontend/app/lib/kratos_checks.server.ts b/packages/frontend/app/lib/kratos_checks.server.ts index a9abaf64e9..5427f6e304 100644 --- a/packages/frontend/app/lib/kratos_checks.server.ts +++ b/packages/frontend/app/lib/kratos_checks.server.ts @@ -3,7 +3,10 @@ import axios from 'axios' import variables from './envConfig.server' export async function isLoggedIn( - cookieHeader?: string | null + cookieHeader?: string | null, + opts?: { + checkIsOperator?: boolean + } ): Promise { if (!variables.authEnabled) { return false @@ -20,6 +23,12 @@ export async function isLoggedIn( ) const isLoggedIn = session.status === 200 && session.data?.active + if ( + opts?.checkIsOperator && + !session.data?.identity.metadata_public.operator + ) { + return false + } return isLoggedIn } catch { @@ -29,7 +38,10 @@ export async function isLoggedIn( export async function checkAuthAndRedirect( url: string, - cookieHeader?: string | null + cookieHeader?: string | null, + opts?: { + checkIsOperator?: boolean + } ) { const { pathname } = new URL(url) const isAuthPath = pathname.startsWith('/auth') @@ -45,7 +57,9 @@ export async function checkAuthAndRedirect( } } - const loggedIn = await isLoggedIn(cookieHeader) + const loggedIn = await isLoggedIn(cookieHeader, { + checkIsOperator: opts?.checkIsOperator + }) // Logged-in users can access all pages except auth pages, with the exception of the manual logout page if (loggedIn) { diff --git a/packages/frontend/app/lib/validate.server.ts b/packages/frontend/app/lib/validate.server.ts index ca74197fe3..6d6f8d9e74 100644 --- a/packages/frontend/app/lib/validate.server.ts +++ b/packages/frontend/app/lib/validate.server.ts @@ -127,3 +127,10 @@ export const updateWalletAddressSchema = z status: z.enum([WalletAddressStatus.Active, WalletAddressStatus.Inactive]) }) .merge(uuidSchema) + +export const createTenantSchema = z.object({ + email: z.string().min(1).email(), + webhookUrl: z.string().min(1).url(), + idpSecret: z.string().min(6), + idpConsentUrl: z.string().min(1).url() +}) diff --git a/packages/frontend/app/root.tsx b/packages/frontend/app/root.tsx index bd9427a1e2..de7e4f4cd7 100644 --- a/packages/frontend/app/root.tsx +++ b/packages/frontend/app/root.tsx @@ -39,6 +39,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { let logoutUrl const loggedIn = await isLoggedIn(cookies) + const isOperator = await isLoggedIn(cookies, { checkIsOperator: true }) const displaySidebar = !variables.authEnabled || loggedIn const authEnabled = variables.authEnabled if (loggedIn) { @@ -70,12 +71,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { publicEnv, logoutUrl, displaySidebar, - authEnabled + authEnabled, + isOperator }) } return json( - { message, publicEnv, logoutUrl, displaySidebar, authEnabled }, + { message, publicEnv, logoutUrl, displaySidebar, authEnabled, isOperator }, { headers: { 'Set-Cookie': await messageStorage.destroySession(session, { @@ -87,8 +89,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } export default function App() { - const { message, publicEnv, logoutUrl, displaySidebar, authEnabled } = - useLoaderData() + const { + message, + publicEnv, + logoutUrl, + displaySidebar, + authEnabled, + isOperator + } = useLoaderData() const [snackbarOpen, setSnackbarOpen] = useState(false) useEffect(() => { @@ -110,7 +118,11 @@ export default function App() {
{displaySidebar && ( - + )}
{ throw json(null, { status: 400, statusText: 'Invalid asset ID.' }) } - const asset = await getAssetInfo({ id: result.data }) + const asset = await getAssetInfo({ id: result.data }, cookies as string) if (!asset) { throw json(null, { status: 404, statusText: 'Asset not found.' }) @@ -45,7 +45,8 @@ export default function AssetDepositLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const assetId = params.assetId if (!assetId) { @@ -73,12 +74,15 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await depositAssetLiquidity({ - assetId, - amount: result.data, - id: v4(), - idempotencyKey: v4() - }) + const response = await depositAssetLiquidity( + { + assetId, + amount: result.data, + id: v4(), + idempotencyKey: v4() + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/assets.$assetId.tsx b/packages/frontend/app/routes/assets.$assetId.tsx index 889310921f..8ce991ccf3 100644 --- a/packages/frontend/app/routes/assets.$assetId.tsx +++ b/packages/frontend/app/routes/assets.$assetId.tsx @@ -48,7 +48,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw json(null, { status: 400, statusText: 'Invalid asset ID.' }) } - const asset = await getAssetInfo({ id: result.data }) + const asset = await getAssetInfo({ id: result.data }, cookies as string) if (!asset) { throw json(null, { status: 404, statusText: 'Asset not found.' }) @@ -282,7 +282,8 @@ export async function action({ request }: ActionFunctionArgs) { } } - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const formData = await request.formData() const intent = formData.get('intent') formData.delete('intent') @@ -296,12 +297,15 @@ export async function action({ request }: ActionFunctionArgs) { return json({ ...actionResponse }, { status: 400 }) } - const response = await updateAsset({ - ...result.data, - ...(result.data.withdrawalThreshold - ? { withdrawalThreshold: result.data.withdrawalThreshold } - : { withdrawalThreshold: undefined }) - }) + const response = await updateAsset( + { + ...result.data, + ...(result.data.withdrawalThreshold + ? { withdrawalThreshold: result.data.withdrawalThreshold } + : { withdrawalThreshold: undefined }) + }, + cookies as string + ) if (!response?.asset) { actionResponse.errors.general.message = [ @@ -320,14 +324,17 @@ export async function action({ request }: ActionFunctionArgs) { return json({ ...actionResponse }, { status: 400 }) } - const response = await setFee({ - assetId: result.data.assetId, - type: FeeType.Sending, - fee: { - fixed: result.data.fixed, - basisPoints: result.data.basisPoints - } - }) + const response = await setFee( + { + assetId: result.data.assetId, + type: FeeType.Sending, + fee: { + fixed: result.data.fixed, + basisPoints: result.data.basisPoints + } + }, + cookies as string + ) if (!response?.fee) { actionResponse.errors.sendingFee.message = [ @@ -351,7 +358,10 @@ export async function action({ request }: ActionFunctionArgs) { }) } - const response = await deleteAsset({ id: result.data.id }) + const response = await deleteAsset( + { id: result.data.id }, + cookies as string + ) if (!response?.asset) { return setMessageAndRedirect({ session, diff --git a/packages/frontend/app/routes/assets.$assetId.withdraw-liquidity.tsx b/packages/frontend/app/routes/assets.$assetId.withdraw-liquidity.tsx index 040ab5d981..ff36ae082e 100644 --- a/packages/frontend/app/routes/assets.$assetId.withdraw-liquidity.tsx +++ b/packages/frontend/app/routes/assets.$assetId.withdraw-liquidity.tsx @@ -20,7 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid asset ID.' }) } - const asset = await getAssetInfo({ id: result.data }) + const asset = await getAssetInfo({ id: result.data }, cookies as string) if (!asset) { throw json(null, { status: 404, statusText: 'Asset not found.' }) @@ -45,7 +45,8 @@ export default function AssetWithdrawLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const assetId = params.assetId if (!assetId) { @@ -73,13 +74,16 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await withdrawAssetLiquidity({ - assetId, - amount: result.data, - id: v4(), - idempotencyKey: v4(), - timeoutSeconds: BigInt(0) - }) + const response = await withdrawAssetLiquidity( + { + assetId, + amount: result.data, + id: v4(), + idempotencyKey: v4(), + timeoutSeconds: BigInt(0) + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx b/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx index 482809e48b..e0726e24e8 100644 --- a/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx +++ b/packages/frontend/app/routes/assets.$assetId_.fee-history.tsx @@ -23,10 +23,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid pagination.' }) } - const asset = await getAssetWithFees({ - ...pagination.data, - id: assetId - }) + const asset = await getAssetWithFees( + { + ...pagination.data, + id: assetId + }, + cookies as string + ) if (!asset) { throw json(null, { status: 404, statusText: 'Asset not found.' }) diff --git a/packages/frontend/app/routes/assets._index.tsx b/packages/frontend/app/routes/assets._index.tsx index 15e25b1798..3cc497764e 100644 --- a/packages/frontend/app/routes/assets._index.tsx +++ b/packages/frontend/app/routes/assets._index.tsx @@ -19,9 +19,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid pagination.' }) } - const assets = await listAssets({ - ...pagination.data - }) + const assets = await listAssets( + { + ...pagination.data + }, + cookies as string + ) let previousPageUrl = '', nextPageUrl = '' diff --git a/packages/frontend/app/routes/assets.create.tsx b/packages/frontend/app/routes/assets.create.tsx index 2f3096ac8d..599be7bf65 100644 --- a/packages/frontend/app/routes/assets.create.tsx +++ b/packages/frontend/app/routes/assets.create.tsx @@ -97,12 +97,16 @@ export async function action({ request }: ActionFunctionArgs) { return json({ errors }, { status: 400 }) } - const response = await createAsset({ - ...result.data, - ...(result.data.withdrawalThreshold - ? { withdrawalThreshold: result.data.withdrawalThreshold } - : { withdrawalThreshold: undefined }) - }) + const cookies = request.headers.get('cookie') + const response = await createAsset( + { + ...result.data, + ...(result.data.withdrawalThreshold + ? { withdrawalThreshold: result.data.withdrawalThreshold } + : { withdrawalThreshold: undefined }) + }, + cookies as string + ) if (!response?.asset) { errors.message = ['Could not create asset. Please try again!'] diff --git a/packages/frontend/app/routes/payments._index.tsx b/packages/frontend/app/routes/payments._index.tsx index c21d46df9e..eae37f67ec 100644 --- a/packages/frontend/app/routes/payments._index.tsx +++ b/packages/frontend/app/routes/payments._index.tsx @@ -52,19 +52,22 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const { type, walletAddressId, ...pagination } = result.data - const payments = await listPayments({ - ...pagination, - ...(type || walletAddressId - ? { - filter: { - ...(type ? { type: { in: type } } : {}), - ...(walletAddressId - ? { walletAddressId: { in: [walletAddressId] } } - : {}) + const payments = await listPayments( + { + ...pagination, + ...(type || walletAddressId + ? { + filter: { + ...(type ? { type: { in: type } } : {}), + ...(walletAddressId + ? { walletAddressId: { in: [walletAddressId] } } + : {}) + } } - } - : {}) - }) + : {}) + }, + cookies as string + ) return json({ payments, diff --git a/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx index 6acc45eb1b..a1757d2e15 100644 --- a/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx +++ b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.tsx @@ -27,7 +27,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } - const incomingPayment = await getIncomingPayment({ id: result.data }) + const incomingPayment = await getIncomingPayment( + { id: result.data }, + cookies as string + ) if (!incomingPayment) { throw json(null, { status: 400, statusText: 'Incoming payment not found.' }) diff --git a/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx index 6931f41092..59004f8df9 100644 --- a/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx +++ b/packages/frontend/app/routes/payments.incoming.$incomingPaymentId.withdraw-liquidity.tsx @@ -29,7 +29,8 @@ export default function IncomingPaymentWithdrawLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const incomingPaymentId = params.incomingPaymentId if (!incomingPaymentId) { @@ -43,11 +44,14 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await createIncomingPaymentWithdrawal({ - incomingPaymentId, - idempotencyKey: v4(), - timeoutSeconds: BigInt(0) - }) + const response = await createIncomingPaymentWithdrawal( + { + incomingPaymentId, + idempotencyKey: v4(), + timeoutSeconds: BigInt(0) + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx index 2257975bd8..41586c346c 100644 --- a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx +++ b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.deposit-liquidity.tsx @@ -31,7 +31,8 @@ export default function OutgoingPaymentDepositLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const outgoingPaymentId = params.outgoingPaymentId if (!outgoingPaymentId) { @@ -45,10 +46,13 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await depositOutgoingPaymentLiquidity({ - outgoingPaymentId, - idempotencyKey: v4() - }) + const response = await depositOutgoingPaymentLiquidity( + { + outgoingPaymentId, + idempotencyKey: v4() + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx index 48d4d112e5..f99ad8804a 100644 --- a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx +++ b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.tsx @@ -32,7 +32,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } - const outgoingPayment = await getOutgoingPayment({ id: result.data }) + const outgoingPayment = await getOutgoingPayment( + { id: result.data }, + cookies as string + ) if (!outgoingPayment) { throw json(null, { status: 400, statusText: 'Outgoing payment not found.' }) diff --git a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx index 0270271b91..4762d3d644 100644 --- a/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx +++ b/packages/frontend/app/routes/payments.outgoing.$outgoingPaymentId.withdraw-liquidity.tsx @@ -31,7 +31,8 @@ export default function OutgoingPaymentWithdrawLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const outgoingPaymentId = params.outgoingPaymentId if (!outgoingPaymentId) { @@ -45,11 +46,14 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await createOutgoingPaymentWithdrawal({ - outgoingPaymentId, - idempotencyKey: v4(), - timeoutSeconds: BigInt(0) - }) + const response = await createOutgoingPaymentWithdrawal( + { + outgoingPaymentId, + idempotencyKey: v4(), + timeoutSeconds: BigInt(0) + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx b/packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx index 4a43a8b5ae..3366882e34 100644 --- a/packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx +++ b/packages/frontend/app/routes/peers.$peerId.deposit-liquidity.tsx @@ -20,7 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid peer ID.' }) } - const peer = await getPeer({ id: result.data }) + const peer = await getPeer({ id: result.data }, cookies as string) if (!peer) { throw json(null, { status: 400, statusText: 'Peer not found.' }) @@ -45,7 +45,8 @@ export default function PeerDepositLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const peerId = params.peerId if (!peerId) { @@ -73,12 +74,15 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await depositPeerLiquidity({ - peerId, - amount: result.data, - id: v4(), - idempotencyKey: v4() - }) + const response = await depositPeerLiquidity( + { + peerId, + amount: result.data, + id: v4(), + idempotencyKey: v4() + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/peers.$peerId.tsx b/packages/frontend/app/routes/peers.$peerId.tsx index 20885c65e9..b6e3818cd6 100644 --- a/packages/frontend/app/routes/peers.$peerId.tsx +++ b/packages/frontend/app/routes/peers.$peerId.tsx @@ -42,7 +42,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw json(null, { status: 400, statusText: 'Invalid peer ID.' }) } - const peer = await getPeer({ id: result.data }) + const peer = await getPeer({ id: result.data }, cookies as string) if (!peer) { throw json(null, { status: 400, statusText: 'Peer not found.' }) @@ -394,7 +394,8 @@ export async function action({ request }: ActionFunctionArgs) { } } - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const formData = await request.formData() const intent = formData.get('intent') formData.delete('intent') @@ -411,12 +412,15 @@ export async function action({ request }: ActionFunctionArgs) { return json({ ...actionResponse }, { status: 400 }) } - const response = await updatePeer({ - ...result.data, - ...(result.data.maxPacketAmount - ? { maxPacketAmount: result.data.maxPacketAmount } - : { maxPacketAmount: undefined }) - }) + const response = await updatePeer( + { + ...result.data, + ...(result.data.maxPacketAmount + ? { maxPacketAmount: result.data.maxPacketAmount } + : { maxPacketAmount: undefined }) + }, + cookies as string + ) if (!response?.peer) { actionResponse.errors.general.message = [ @@ -436,24 +440,27 @@ export async function action({ request }: ActionFunctionArgs) { return json({ ...actionResponse }, { status: 400 }) } - const response = await updatePeer({ - id: result.data.id, - http: { - ...(result.data.incomingAuthTokens - ? { - incoming: { - authTokens: result.data.incomingAuthTokens - ?.replace(/ /g, '') - .split(',') + const response = await updatePeer( + { + id: result.data.id, + http: { + ...(result.data.incomingAuthTokens + ? { + incoming: { + authTokens: result.data.incomingAuthTokens + ?.replace(/ /g, '') + .split(',') + } } - } - : {}), - outgoing: { - endpoint: result.data.outgoingEndpoint, - authToken: result.data.outgoingAuthToken + : {}), + outgoing: { + endpoint: result.data.outgoingEndpoint, + authToken: result.data.outgoingAuthToken + } } - } - }) + }, + cookies as string + ) if (!response?.peer) { actionResponse.errors.general.message = [ @@ -477,7 +484,10 @@ export async function action({ request }: ActionFunctionArgs) { }) } - const response = await deletePeer({ input: { id: result.data.id } }) + const response = await deletePeer( + { input: { id: result.data.id } }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ session, diff --git a/packages/frontend/app/routes/peers.$peerId.withdraw-liquidity.tsx b/packages/frontend/app/routes/peers.$peerId.withdraw-liquidity.tsx index 99df3bbf26..f9a68f0478 100644 --- a/packages/frontend/app/routes/peers.$peerId.withdraw-liquidity.tsx +++ b/packages/frontend/app/routes/peers.$peerId.withdraw-liquidity.tsx @@ -20,7 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid peer ID.' }) } - const peer = await getPeer({ id: result.data }) + const peer = await getPeer({ id: result.data }, cookies as string) if (!peer) { throw json(null, { status: 400, statusText: 'Peer not found.' }) @@ -45,7 +45,8 @@ export default function PeerWithdrawLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const peerId = params.peerId if (!peerId) { @@ -73,13 +74,16 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await withdrawPeerLiquidity({ - peerId, - amount: result.data, - id: v4(), - idempotencyKey: v4(), - timeoutSeconds: BigInt(0) - }) + const response = await withdrawPeerLiquidity( + { + peerId, + amount: result.data, + id: v4(), + idempotencyKey: v4(), + timeoutSeconds: BigInt(0) + }, + cookies as string + ) if (!response?.success) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/peers._index.tsx b/packages/frontend/app/routes/peers._index.tsx index 2ea8582111..128b7be112 100644 --- a/packages/frontend/app/routes/peers._index.tsx +++ b/packages/frontend/app/routes/peers._index.tsx @@ -19,9 +19,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid pagination.' }) } - const peers = await listPeers({ - ...pagination.data - }) + const peers = await listPeers( + { + ...pagination.data + }, + cookies as string + ) let previousPageUrl = '', nextPageUrl = '' diff --git a/packages/frontend/app/routes/peers.create.tsx b/packages/frontend/app/routes/peers.create.tsx index 0ad7be7c93..adf83f6195 100644 --- a/packages/frontend/app/routes/peers.create.tsx +++ b/packages/frontend/app/routes/peers.create.tsx @@ -22,7 +22,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const cookies = request.headers.get('cookie') await checkAuthAndRedirect(request.url, cookies) - return json({ assets: await loadAssets() }) + return json({ assets: await loadAssets(cookies as string) }) } export default function CreatePeerPage() { @@ -242,34 +242,38 @@ export async function action({ request }: ActionFunctionArgs) { return json({ errors }, { status: 400 }) } - const response = await createPeer({ - name: result.data.name, - http: { - outgoing: { - endpoint: result.data.outgoingEndpoint, - authToken: result.data.outgoingAuthToken + const cookies = request.headers.get('cookie') + const response = await createPeer( + { + name: result.data.name, + http: { + outgoing: { + endpoint: result.data.outgoingEndpoint, + authToken: result.data.outgoingAuthToken + }, + incoming: result.data.incomingAuthTokens + ? { + authTokens: result.data.incomingAuthTokens + ?.replace(/ /g, '') + .split(',') + } + : undefined }, - incoming: result.data.incomingAuthTokens - ? { - authTokens: result.data.incomingAuthTokens - ?.replace(/ /g, '') - .split(',') - } - : undefined + assetId: result.data.asset, + staticIlpAddress: result.data.staticIlpAddress, + ...(result.data.maxPacketAmount + ? { maxPacketAmount: result.data.maxPacketAmount } + : { maxPacketAmount: undefined }) }, - assetId: result.data.asset, - staticIlpAddress: result.data.staticIlpAddress, - ...(result.data.maxPacketAmount - ? { maxPacketAmount: result.data.maxPacketAmount } - : { maxPacketAmount: undefined }) - }) + cookies as string + ) if (!response?.peer) { errors.message = ['Could not create peer. Please try again!'] return json({ errors }, { status: 400 }) } - const session = await messageStorage.getSession(request.headers.get('cookie')) + const session = await messageStorage.getSession(cookies) return setMessageAndRedirect({ session, diff --git a/packages/frontend/app/routes/tenants.$tenantId.tsx b/packages/frontend/app/routes/tenants.$tenantId.tsx new file mode 100644 index 0000000000..383e943790 --- /dev/null +++ b/packages/frontend/app/routes/tenants.$tenantId.tsx @@ -0,0 +1,114 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { + Form, + Outlet, + useFormAction, + 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, Input } from '~/components/ui' +import { + ConfirmationDialog, + type ConfirmationDialogRef +} from '~/components/ConfirmationDialog' +import { getTenant } from '~/lib/api/tenant.server' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' + +export async function loader({ request, params }: LoaderFunctionArgs) { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies, { checkIsOperator: true }) + + 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 getTenant({ id: result.data }, cookies as string) + + if (!tenant) { + throw json(null, { status: 404, statusText: 'Tenant not found.' }) + } + + return json({ tenant }) +} + +export default function ViewTenantPage() { + const { tenant } = useLoaderData() + const navigation = useNavigation() + const formAction = useFormAction() + const submit = useSubmit() + const dialogRef = useRef(null) + + const isSubmitting = navigation.state === 'submitting' + const currentPageAction = isSubmitting && navigation.formAction === formAction + + const [formData, setFormData] = useState() + + 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

+
+
+
+
+
+ + +
+
+
+
+
+ {/* DELETE TENANT - Danger zone */} + +
+ + + +
+
+
+ + +
+ ) +} diff --git a/packages/frontend/app/routes/tenants._index.tsx b/packages/frontend/app/routes/tenants._index.tsx new file mode 100644 index 0000000000..ac9dbc9b8b --- /dev/null +++ b/packages/frontend/app/routes/tenants._index.tsx @@ -0,0 +1,107 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData, useNavigate } from '@remix-run/react' +import { PageHeader } from '~/components' +import { Button, Table } from '~/components/ui' +import { paginationSchema } from '~/lib/validate.server' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' +import { listTenants } from '../lib/api/tenant.server' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies, { checkIsOperator: true }) + + 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 tenants = await listTenants( + { + ...pagination.data + }, + cookies as string + ) + + let previousPageUrl = '', + nextPageUrl = '' + + if (tenants.pageInfo.hasPreviousPage) { + previousPageUrl = `/tenants?before=${tenants.pageInfo.startCursor}` + } + + if (tenants.pageInfo.hasNextPage) { + nextPageUrl = `/tenants?after=${tenants.pageInfo.endCursor}` + } + + return json({ tenants, previousPageUrl, nextPageUrl }) +} + +export default function TenantsPage() { + // TODO: get paginated tenants from API + const { tenants, previousPageUrl, nextPageUrl } = + useLoaderData() + const navigate = useNavigate() + + return ( +
+
+ +
+

Tenants

+
+
+ +
+
+ + + + {tenants.edges.length ? ( + tenants.edges.map((tenant) => ( + navigate(`/tenants/${tenant.node.id}`)} + > + {tenant.node.id} + + )) + ) : ( + + + 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..6b78cd6a7d --- /dev/null +++ b/packages/frontend/app/routes/tenants.create.tsx @@ -0,0 +1,137 @@ +import { json, type ActionFunctionArgs } from '@remix-run/node' +import { Form, useActionData, useNavigation } from '@remix-run/react' +import { PageHeader } from '~/components' +import { Button, ErrorPanel, Input } 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' +import { TenantEndpointType } from '~/generated/graphql' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies, { checkIsOperator: true }) + 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

+
+
+
+ + + + +
+
+
+
+ +
+
+
+ {/* 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 { webhookUrl, ...restOfData } = result.data + const cookies = request.headers.get('cookie') + const response = await createTenant( + { + ...restOfData, + endpoints: [ + { type: TenantEndpointType.WebhookBaseUrl, value: webhookUrl } + ] + }, + cookies as string + ) + + if (!response?.tenant) { + errors.message = ['Could not create tenant. Please try again!'] + return json({ errors }, { status: 400 }) + } + + const session = await messageStorage.getSession(cookies) + + 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 b9a85482a6..59e58818ec 100644 --- a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx +++ b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx @@ -34,7 +34,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw json(null, { status: 400, statusText: 'Invalid wallet address ID.' }) } - const walletAddress = await getWalletAddress({ id: result.data }) + const walletAddress = await getWalletAddress( + { id: result.data }, + cookies as string + ) if (!walletAddress) { throw json(null, { status: 404, statusText: 'Wallet address not found.' }) @@ -235,9 +238,13 @@ export async function action({ request }: ActionFunctionArgs) { return json({ ...actionResponse }, { status: 400 }) } - const response = await updateWalletAddress({ - ...result.data - }) + const cookies = request.headers.get('cookie') + const response = await updateWalletAddress( + { + ...result.data + }, + cookies as string + ) if (!response?.walletAddress) { actionResponse.errors.message = [ @@ -246,7 +253,7 @@ export async function action({ request }: ActionFunctionArgs) { return json({ ...actionResponse }, { status: 400 }) } - const session = await messageStorage.getSession(request.headers.get('cookie')) + const session = await messageStorage.getSession(cookies) return setMessageAndRedirect({ session, diff --git a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx index 2e4a64b877..4ca35a3478 100644 --- a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx +++ b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.withdraw-liquidity.tsx @@ -29,7 +29,8 @@ export default function WalletAddressWithdrawLiquidity() { } export async function action({ request, params }: ActionFunctionArgs) { - const session = await messageStorage.getSession(request.headers.get('cookie')) + const cookies = request.headers.get('cookie') + const session = await messageStorage.getSession(cookies) const walletAddressId = params.walletAddressId if (!walletAddressId) { @@ -43,12 +44,15 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } - const response = await createWalletAddressWithdrawal({ - id: v4(), - walletAddressId, - idempotencyKey: v4(), - timeoutSeconds: BigInt(0) - }) + const response = await createWalletAddressWithdrawal( + { + id: v4(), + walletAddressId, + idempotencyKey: v4(), + timeoutSeconds: BigInt(0) + }, + cookies as string + ) if (!response?.withdrawal) { return setMessageAndRedirect({ diff --git a/packages/frontend/app/routes/wallet-addresses._index.tsx b/packages/frontend/app/routes/wallet-addresses._index.tsx index f8dfe9453b..ead88a6728 100644 --- a/packages/frontend/app/routes/wallet-addresses._index.tsx +++ b/packages/frontend/app/routes/wallet-addresses._index.tsx @@ -20,9 +20,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { throw json(null, { status: 400, statusText: 'Invalid pagination.' }) } - const walletAddresses = await listWalletAddresses({ - ...pagination.data - }) + const walletAddresses = await listWalletAddresses( + { + ...pagination.data + }, + cookies as string + ) let previousPageUrl = '', nextPageUrl = '' diff --git a/packages/frontend/app/routes/wallet-addresses.create.tsx b/packages/frontend/app/routes/wallet-addresses.create.tsx index 5681b3589f..ce47b072e4 100644 --- a/packages/frontend/app/routes/wallet-addresses.create.tsx +++ b/packages/frontend/app/routes/wallet-addresses.create.tsx @@ -19,7 +19,7 @@ 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 json({ assets: await loadAssets() }) + return json({ assets: await loadAssets(cookies as string) }) } export default function CreateWalletAddressPage() { @@ -109,19 +109,23 @@ export async function action({ request }: ActionFunctionArgs) { return json({ errors }, { status: 400 }) } - const response = await createWalletAddress({ - url: `${getOpenPaymentsUrl()}${result.data.name}`, - publicName: result.data.publicName, - assetId: result.data.asset, - additionalProperties: [] - }) + const cookies = request.headers.get('cookie') + const response = await createWalletAddress( + { + url: `${getOpenPaymentsUrl()}${result.data.name}`, + publicName: result.data.publicName, + assetId: result.data.asset, + additionalProperties: [] + }, + cookies as string + ) if (!response?.walletAddress) { errors.message = ['Could not create wallet address. Please try again!'] return json({ errors }, { status: 400 }) } - const session = await messageStorage.getSession(request.headers.get('cookie')) + const session = await messageStorage.getSession(cookies) return setMessageAndRedirect({ session, diff --git a/packages/frontend/app/routes/webhook-events.tsx b/packages/frontend/app/routes/webhook-events.tsx index f23ad24004..df0a09591f 100644 --- a/packages/frontend/app/routes/webhook-events.tsx +++ b/packages/frontend/app/routes/webhook-events.tsx @@ -30,10 +30,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } const { type, ...pagination } = result.data - const webhooks = await listWebhooks({ - ...pagination, - ...(type ? { filter: { type: { in: type } } } : {}) - }) + const webhooks = await listWebhooks( + { + ...pagination, + ...(type ? { filter: { type: { in: type } } } : {}) + }, + cookies as string + ) let previousPageUrl = '', nextPageUrl = '' diff --git a/packages/frontend/kratos/scripts/userInvitation.ts b/packages/frontend/kratos/scripts/userInvitation.ts index 347cdb7b2f..91d12453e3 100644 --- a/packages/frontend/kratos/scripts/userInvitation.ts +++ b/packages/frontend/kratos/scripts/userInvitation.ts @@ -58,7 +58,7 @@ const createIdentity = async () => { traits: { email: USER_EMAIL }, - metadata_admin: { + metadata_public: { [ROLE]: true } }, diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index d2f1d62ed5..dea9da36e2 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -345,10 +345,12 @@ export type CreateTenantEndpointsInput = { }; export type CreateTenantInput = { + /** Email of the tenant */ + email: Scalars['String']['input']; /** List of endpoints types for the tenant */ endpoints: Array; /** IDP Endpoint */ - idpConsentEndpoint: Scalars['String']['input']; + idpConsentUrl: Scalars['String']['input']; /** IDP Secret */ idpSecret: Scalars['String']['input']; }; @@ -1309,6 +1311,8 @@ export type Tenant = Model & { __typename?: 'Tenant'; /** Date-time of creation */ createdAt: Scalars['String']['output']; + /** Tenant Email for Kratos identity & recovery */ + email: Scalars['String']['output']; /** List of tenant endpoints associated with this tenant */ endpoints: Array; /** Tenant ID that is used in subsequent resources */ @@ -2349,6 +2353,7 @@ export type SetFeeResponseResolvers = { createdAt?: Resolver; + email?: Resolver; endpoints?: Resolver, ParentType, ContextType>; id?: Resolver; kratosIdentityId?: Resolver; diff --git a/packages/mock-account-service-lib/src/requesters.ts b/packages/mock-account-service-lib/src/requesters.ts index 308ea7788b..98b18484d5 100644 --- a/packages/mock-account-service-lib/src/requesters.ts +++ b/packages/mock-account-service-lib/src/requesters.ts @@ -35,7 +35,8 @@ export function createRequesters( createTenant: ( idpConsentUrl: string, idpSecret: string, - endpoints: EndpointType[] + endpoints: EndpointType[], + email: string ) => Promise createPeer: ( staticIlpAddress: string, @@ -82,8 +83,15 @@ export function createRequesters( return { createAsset: (code, scale, liquidityThreshold) => createAsset(apolloClient, code, scale, liquidityThreshold), - createTenant: (idpConsentUrl, idpSecret, endpoints) => - createTenant(apolloClient, logger, idpConsentUrl, idpSecret, endpoints), + createTenant: (idpConsentUrl, idpSecret, endpoints, email) => + createTenant( + apolloClient, + logger, + idpConsentUrl, + idpSecret, + endpoints, + email + ), createPeer: ( staticIlpAddress, outgoingEndpoint, @@ -167,7 +175,8 @@ export async function createTenant( logger: Logger, idpConsentUrl: string, idpSecret: string, - endpoints: EndpointType[] + endpoints: EndpointType[], + email: string ): Promise { const createTenantMutation = gql` mutation CreateTenant($input: CreateTenantInput!) { @@ -183,7 +192,8 @@ export async function createTenant( input: { idpConsentUrl, idpSecret, - endpoints + endpoints, + email } } diff --git a/packages/mock-account-service-lib/src/seed.ts b/packages/mock-account-service-lib/src/seed.ts index bf016a556a..7125889127 100644 --- a/packages/mock-account-service-lib/src/seed.ts +++ b/packages/mock-account-service-lib/src/seed.ts @@ -44,7 +44,12 @@ export async function setupFromSeed( const tenants: Record = {} for (const { name, idpConsentUrl, idpSecret, endpoints } of config.seed .tenants) { - const { tenant } = await createTenant(idpConsentUrl, idpSecret, endpoints) + const { tenant } = await createTenant( + idpConsentUrl, + idpSecret, + endpoints, + `${name}@example.com` + ) if (!tenant) { throw new Error('error creating tenant') } @@ -142,7 +147,7 @@ export async function setupFromSeed( account.name, `${config.publicHost}/${account.path}`, accountAsset.id, - tenants['PrimaryTenant'] + tenants[config.seed.tenants[0].name] ) await mockAccounts.setWalletAddress( diff --git a/test/integration/lib/generated/graphql.ts b/test/integration/lib/generated/graphql.ts index d2f1d62ed5..dea9da36e2 100644 --- a/test/integration/lib/generated/graphql.ts +++ b/test/integration/lib/generated/graphql.ts @@ -345,10 +345,12 @@ export type CreateTenantEndpointsInput = { }; export type CreateTenantInput = { + /** Email of the tenant */ + email: Scalars['String']['input']; /** List of endpoints types for the tenant */ endpoints: Array; /** IDP Endpoint */ - idpConsentEndpoint: Scalars['String']['input']; + idpConsentUrl: Scalars['String']['input']; /** IDP Secret */ idpSecret: Scalars['String']['input']; }; @@ -1309,6 +1311,8 @@ export type Tenant = Model & { __typename?: 'Tenant'; /** Date-time of creation */ createdAt: Scalars['String']['output']; + /** Tenant Email for Kratos identity & recovery */ + email: Scalars['String']['output']; /** List of tenant endpoints associated with this tenant */ endpoints: Array; /** Tenant ID that is used in subsequent resources */ @@ -2349,6 +2353,7 @@ export type SetFeeResponseResolvers = { createdAt?: Resolver; + email?: Resolver; endpoints?: Resolver, ParentType, ContextType>; id?: Resolver; kratosIdentityId?: Resolver;