From 9fb3720383599f9dffb539f627be0d60988c8aab Mon Sep 17 00:00:00 2001 From: Radu-Cristian Popa Date: Thu, 24 Oct 2024 20:10:30 +0200 Subject: [PATCH] feat(wallet): freeze card; pin; card details (#1679) --- docker/dev/.env.example | 2 + docker/dev/docker-compose.yml | 2 + .../wallet/backend/src/account/service.ts | 12 +- packages/wallet/backend/src/app.ts | 15 +- .../wallet/backend/src/auth/controller.ts | 5 +- .../wallet/backend/src/card/controller.ts | 85 +++++--- packages/wallet/backend/src/card/service.ts | 96 +++++--- packages/wallet/backend/src/card/types.ts | 31 +-- .../wallet/backend/src/card/validation.ts | 22 +- packages/wallet/backend/src/config/env.ts | 4 +- packages/wallet/backend/src/gatehub/client.ts | 158 +++++++++----- .../wallet/backend/src/gatehub/service.ts | 26 ++- .../wallet/backend/src/middleware/isAuth.ts | 15 +- .../backend/src/middleware/withSession.ts | 8 +- packages/wallet/backend/src/reorder.ts | 6 +- .../wallet/backend/src/user/controller.ts | 1 + .../backend/tests/cards/controller.test.ts | 50 ++--- packages/wallet/frontend/next.config.js | 4 +- packages/wallet/frontend/package.json | 2 + .../dialogs/TerminateCardDialog.tsx | 143 ++++++++++++ .../components/dialogs/UserCardPINDialog.tsx | 190 +++++++++------- .../dialogs/UserCardSpendingLimitDialog.tsx | 6 +- .../src/components/userCards/UserCard.tsx | 205 +++++++++++++++--- .../components/userCards/UserCardActions.tsx | 132 ++++++++--- .../components/userCards/UserCardContext.tsx | 103 ++++++++- .../components/userCards/UserCardSettings.tsx | 54 ++++- packages/wallet/frontend/src/lib/api/card.ts | 183 +++++++++------- .../wallet/frontend/src/lib/httpClient.ts | 1 + .../wallet/frontend/src/pages/auth/signup.tsx | 18 +- .../src/pages/{card/index.tsx => card.tsx} | 10 +- packages/wallet/frontend/src/pages/index.tsx | 3 + packages/wallet/frontend/src/pages/kyc.tsx | 17 +- .../wallet/frontend/src/utils/constants.ts | 1 + packages/wallet/frontend/src/utils/helpers.ts | 20 ++ packages/wallet/frontend/tailwind.config.js | 16 ++ packages/wallet/frontend/tsconfig.json | 2 +- packages/wallet/shared/src/types/card.ts | 18 ++ pnpm-lock.yaml | 20 ++ 38 files changed, 1228 insertions(+), 458 deletions(-) create mode 100644 packages/wallet/frontend/src/components/dialogs/TerminateCardDialog.tsx rename packages/wallet/frontend/src/pages/{card/index.tsx => card.tsx} (80%) diff --git a/docker/dev/.env.example b/docker/dev/.env.example index bc386ab02..fc420d8ef 100644 --- a/docker/dev/.env.example +++ b/docker/dev/.env.example @@ -17,6 +17,8 @@ GATEHUB_ACCOUNT_PRODUCT_CODE= GATEHUB_CARD_PRODUCT_CODE= GATEHUB_NAME_ON_CARD= GATEHUB_CARD_PP_PREFIX= +CARD_PIN_HREF= +CARD_DATA_HREF= # commerce env variables # encoded base 64 private key diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 97be1de4d..422f17b21 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -61,6 +61,8 @@ services: GATEHUB_CARD_PRODUCT_CODE: ${GATEHUB_CARD_PRODUCT_CODE} GATEHUB_NAME_ON_CARD: ${GATEHUB_NAME_ON_CARD} GATEHUB_CARD_PP_PREFIX: ${GATEHUB_CARD_PP_PREFIX} + CARD_DATA_HREF: ${CARD_DATA_HREF} + CARD_PIN_HREF: ${CARD_PIN_HREF} restart: always networks: - testnet diff --git a/packages/wallet/backend/src/account/service.ts b/packages/wallet/backend/src/account/service.ts index 6c42cd75e..1868d0024 100644 --- a/packages/wallet/backend/src/account/service.ts +++ b/packages/wallet/backend/src/account/service.ts @@ -14,6 +14,7 @@ type CreateAccountArgs = { name: string assetId: string isDefaultCardsAccount?: boolean + cardId?: string } interface IAccountService { @@ -93,7 +94,8 @@ export class AccountService implements IAccountService { assetCode: asset.code, assetId: args.assetId, assetScale: asset.scale, - gateHubWalletId + gateHubWalletId, + cardId: args.cardId }) // On creation account will have balance 0 @@ -208,7 +210,7 @@ export class AccountService implements IAccountService { return Number( balances.find((balance) => balance.vault.asset_code === account.assetCode) - ?.total ?? 0 + ?.available ?? 0 ) } @@ -230,7 +232,8 @@ export class AccountService implements IAccountService { public async createDefaultAccount( userId: string, name = 'USD Account', - isDefaultCardsAccount = false + isDefaultCardsAccount = false, + cardId?: string ): Promise { const asset = (await this.rafikiClient.listAssets({ first: 100 })).find( (asset) => asset.code === 'EUR' && asset.scale === DEFAULT_ASSET_SCALE @@ -242,7 +245,8 @@ export class AccountService implements IAccountService { name, userId, assetId: asset.id, - isDefaultCardsAccount + isDefaultCardsAccount, + cardId }) return account diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index fe138dcce..602387a50 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -162,7 +162,8 @@ export class App { cors({ origin: [ 'http://localhost:4003', - `https://${env.RAFIKI_MONEY_FRONTEND_HOST}` + `https://${env.RAFIKI_MONEY_FRONTEND_HOST}`, + `https://wallet.${env.RAFIKI_MONEY_FRONTEND_HOST}` ], credentials: true }) @@ -327,11 +328,7 @@ export class App { ) // Cards - router.get( - '/customers/:customerId/cards', - isAuth, - cardController.getCardsByCustomer - ) + router.get('/customers/cards', isAuth, cardController.getCardsByCustomer) router.get('/cards/:cardId/details', isAuth, cardController.getCardDetails) router.get( '/cards/:cardId/transactions', @@ -358,7 +355,7 @@ export class App { cardController.getPin ) router.get( - '/cards/:cardId/change-pin', + '/cards/:cardId/change-pin-token', this.ensureGateHubProductionEnv, isAuth, cardController.getTokenForPinChange @@ -381,11 +378,11 @@ export class App { isAuth, cardController.unlock ) - router.put( + router.delete( '/cards/:cardId/block', this.ensureGateHubProductionEnv, isAuth, - cardController.permanentlyBlockCard + cardController.closeCard ) // Return an error for invalid routes diff --git a/packages/wallet/backend/src/auth/controller.ts b/packages/wallet/backend/src/auth/controller.ts index 4b51c5fe4..28705429a 100644 --- a/packages/wallet/backend/src/auth/controller.ts +++ b/packages/wallet/backend/src/auth/controller.ts @@ -26,10 +26,10 @@ export class AuthController implements IAuthController { signUp = async (req: Request, res: CustomResponse, next: NextFunction) => { try { const { - body: { email, password } + body: { email, password, acceptedCardTerms } } = await validate(signUpBodySchema, req) - await this.authService.signUp({ email, password }) + await this.authService.signUp({ email, password, acceptedCardTerms }) res .status(201) @@ -54,6 +54,7 @@ export class AuthController implements IAuthController { req.session.user = { id: user.id, email: user.email, + // TODO: REMOVE NEEDSWALLET needsWallet: !user.gateHubUserId, needsIDProof: !user.kycVerified, customerId: user.customerId diff --git a/packages/wallet/backend/src/card/controller.ts b/packages/wallet/backend/src/card/controller.ts index 24ee33654..2f17d8ee2 100644 --- a/packages/wallet/backend/src/card/controller.ts +++ b/packages/wallet/backend/src/card/controller.ts @@ -1,21 +1,23 @@ import { Request, Response, NextFunction } from 'express' -import { Controller } from '@shared/backend' +import { + BadRequest, + Controller, + InternalServerError, + NotFound +} from '@shared/backend' import { CardService } from '@/card/service' import { toSuccessResponse } from '@shared/backend' import { ICardDetailsRequest, ICardDetailsResponse, - ICardDetailsWithPinStatusResponse, ICardLimitRequest, ICardLimitResponse, ICardLockRequest, - ICardResponse, ICardUnlockRequest } from './types' -import { IGetTransactionsResponse } from '@wallet/shared/src' +import { ICardResponse, IGetTransactionsResponse } from '@wallet/shared' import { validate } from '@/shared/validate' import { - getCardsByCustomerSchema, getCardDetailsSchema, lockCardSchema, unlockCardSchema, @@ -26,10 +28,12 @@ import { permanentlyBlockCardSchema, getTokenForPinChange } from './validation' +import { Logger } from 'winston' +import { UserService } from '@/user/service' export interface ICardController { - getCardsByCustomer: Controller - getCardDetails: Controller + getCardsByCustomer: Controller + getCardDetails: Controller getCardLimits: Controller createOrOverrideCardLimits: Controller getCardTransactions: Controller @@ -37,11 +41,15 @@ export interface ICardController { changePin: Controller lock: Controller unlock: Controller - permanentlyBlockCard: Controller + closeCard: Controller } export class CardController implements ICardController { - constructor(private cardService: CardService) {} + constructor( + private cardService: CardService, + private userService: UserService, + private logger: Logger + ) {} public getCardsByCustomer = async ( req: Request, @@ -49,10 +57,19 @@ export class CardController implements ICardController { next: NextFunction ) => { try { - const { params } = await validate(getCardsByCustomerSchema, req) - const { customerId } = params + const customerId = req.session.user.customerId + + if (!customerId) { + this.logger.error( + `Customer id was not found on session object for user ${req.session.user.id}` + ) + throw new InternalServerError() + } - const cards = await this.cardService.getCardsByCustomer(customerId) + const cards = await this.cardService.getCardsByCustomer( + req.session.user.id, + customerId + ) res.status(200).json(toSuccessResponse(cards)) } catch (error) { next(error) @@ -70,7 +87,10 @@ export class CardController implements ICardController { const { cardId } = params const { publicKeyBase64 } = query - const requestBody: ICardDetailsRequest = { cardId, publicKeyBase64 } + const requestBody: ICardDetailsRequest = { + cardId, + publicKey: publicKeyBase64 + } const cardDetails = await this.cardService.getCardDetails( userId, requestBody @@ -155,7 +175,10 @@ export class CardController implements ICardController { const { cardId } = params const { publicKeyBase64 } = query - const requestBody: ICardDetailsRequest = { cardId, publicKeyBase64 } + const requestBody: ICardDetailsRequest = { + cardId, + publicKey: publicKeyBase64 + } const cardPin = await this.cardService.getPin(userId, requestBody) res.status(200).json(toSuccessResponse(cardPin)) } catch (error) { @@ -191,13 +214,8 @@ export class CardController implements ICardController { const { cardId } = params const { token, cypher } = body - const result = await this.cardService.changePin( - userId, - cardId, - token, - cypher - ) - res.status(201).json(toSuccessResponse(result)) + await this.cardService.changePin(userId, cardId, token, cypher) + res.status(201).json(toSuccessResponse()) } catch (error) { next(error) } @@ -239,23 +257,30 @@ export class CardController implements ICardController { } } - public permanentlyBlockCard = async ( + public closeCard = async ( req: Request, res: Response, next: NextFunction ) => { try { const userId = req.session.user.id - const { params, query } = await validate(permanentlyBlockCardSchema, req) + const { params, body } = await validate(permanentlyBlockCardSchema, req) const { cardId } = params - const { reasonCode } = query + const { reasonCode, password } = body - const result = await this.cardService.permanentlyBlockCard( - userId, - cardId, - reasonCode - ) - res.status(200).json(toSuccessResponse(result)) + const user = await this.userService.getById(userId) + + if (!user) { + throw new NotFound() + } + + const passwordIsValid = await user?.verifyPassword(password) + if (!passwordIsValid) { + throw new BadRequest('Password is not valid') + } + + await this.cardService.closeCard(userId, cardId, reasonCode) + res.status(200).json(toSuccessResponse()) } catch (error) { next(error) } diff --git a/packages/wallet/backend/src/card/service.ts b/packages/wallet/backend/src/card/service.ts index 99a18d0fe..4d5d507dd 100644 --- a/packages/wallet/backend/src/card/service.ts +++ b/packages/wallet/backend/src/card/service.ts @@ -1,49 +1,63 @@ import { GateHubClient } from '../gatehub/client' import { + CloseCardReason, ICardDetailsRequest, ICardDetailsResponse, - ICardDetailsWithPinStatusResponse, ICardLimitRequest, ICardLimitResponse, ICardLockRequest, - ICardResponse, ICardUnlockRequest } from './types' import { IGetTransactionsResponse } from '@wallet/shared/src' import { LockReasonCode } from '@wallet/shared/src' -import { NotFound } from '@shared/backend' -import { BlockReasonCode } from '@wallet/shared/src' +import { InternalServerError, NotFound } from '@shared/backend' import { AccountService } from '@/account/service' +import { ICardResponse } from '@wallet/shared' +import { UserService } from '@/user/service' +import { Logger } from 'winston' import { User } from '@/user/model' export class CardService { constructor( private gateHubClient: GateHubClient, - private accountService: AccountService + private accountService: AccountService, + private userService: UserService, + private logger: Logger ) {} - async getCardsByCustomer(customerId: string): Promise { - return this.gateHubClient.getCardsByCustomer(customerId) - } - - async getCardDetails( + async getCardsByCustomer( userId: string, - requestBody: ICardDetailsRequest - ): Promise { + customerId: string + ): Promise { const user = await User.query().findById(userId) if (!user) { throw new NotFound() } + const gateHubUserId = await this.ensureGatehubUserUuid(userId) + const cards = await this.gateHubClient.getCardsByCustomer( + customerId, + gateHubUserId + ) + + Object.assign(cards[0], { + isPinSet: user.isPinSet, + walletAddress: user.cardWalletAddress + }) + + return cards + } + + async getCardDetails( + userId: string, + requestBody: ICardDetailsRequest + ): Promise { const { cardId } = requestBody await this.ensureAccountExists(userId, cardId) - const cardDetails = await this.gateHubClient.getCardDetails(requestBody) + const gateHubUserId = await this.ensureGatehubUserUuid(userId) - return { - ...cardDetails, - isPinSet: user.isPinSet - } + return await this.gateHubClient.getCardDetails(gateHubUserId, requestBody) } async getCardTransactions( @@ -82,14 +96,19 @@ export class CardService { ): Promise { const { cardId } = requestBody await this.ensureAccountExists(userId, cardId) + const gateHubUserId = await this.ensureGatehubUserUuid(userId) - return this.gateHubClient.getPin(requestBody) + return this.gateHubClient.getPin(gateHubUserId, requestBody) } async getTokenForPinChange(userId: string, cardId: string): Promise { await this.ensureAccountExists(userId, cardId) - const token = await this.gateHubClient.getTokenForPinChange(cardId) + const gateHubUserId = await this.ensureGatehubUserUuid(userId) + const token = await this.gateHubClient.getTokenForPinChange( + gateHubUserId, + cardId + ) return token } @@ -115,7 +134,14 @@ export class CardService { ): Promise { await this.ensureAccountExists(userId, cardId) - return this.gateHubClient.lockCard(cardId, reasonCode, requestBody) + const gateHubUserId = await this.ensureGatehubUserUuid(userId) + + return this.gateHubClient.lockCard( + cardId, + gateHubUserId, + reasonCode, + requestBody + ) } async unlock( @@ -125,17 +151,21 @@ export class CardService { ): Promise { await this.ensureAccountExists(userId, cardId) - return this.gateHubClient.unlockCard(cardId, requestBody) + const gateHubUserId = await this.ensureGatehubUserUuid(userId) + + return this.gateHubClient.unlockCard(cardId, gateHubUserId, requestBody) } - async permanentlyBlockCard( + async closeCard( userId: string, cardId: string, - reasonCode: BlockReasonCode - ): Promise { + reasonCode: CloseCardReason + ): Promise { await this.ensureAccountExists(userId, cardId) - return this.gateHubClient.permanentlyBlockCard(cardId, reasonCode) + const gateHubUserId = await this.ensureGatehubUserUuid(userId) + + await this.gateHubClient.closeCard(gateHubUserId, cardId, reasonCode) } private async ensureAccountExists( @@ -147,4 +177,20 @@ export class CardService { throw new NotFound('Card not found or not associated with the user.') } } + + private async ensureGatehubUserUuid(userId: string): Promise { + const user = await this.userService.getById(userId) + + if (!user) { + this.logger.error(`Could not find user with id: ${userId}`) + throw new InternalServerError() + } + + if (!user.gateHubUserId) { + this.logger.error(`User ${user.id} does not have a GateHub ID.`) + throw new InternalServerError() + } + + return user.gateHubUserId + } } diff --git a/packages/wallet/backend/src/card/types.ts b/packages/wallet/backend/src/card/types.ts index f313476e9..b94b2ea8e 100644 --- a/packages/wallet/backend/src/card/types.ts +++ b/packages/wallet/backend/src/card/types.ts @@ -1,19 +1,16 @@ +import { ICardResponse } from '@wallet/shared' import { CardLimitType } from '@wallet/shared/src' export type GateHubCardCurrency = 'EUR' export interface ICardDetailsRequest { cardId: string - publicKeyBase64: string + publicKey: string } export interface ICardDetailsResponse { cipher: string } -export interface ICardDetailsWithPinStatusResponse - extends ICardDetailsResponse { - isPinSet: boolean -} export interface ILinksResponse { token: string | null @@ -146,22 +143,6 @@ export interface ICreateCustomerResponse { } } -export interface ICardResponse { - sourceId: string - nameOnCard: string - productCode: string - id: string - accountId: string - accountSourceId: string - maskedPan: string - status: string - statusReasonCode: string | null - lockLevel: string | null - expiryDate: string - customerId: string - customerSourceId: string -} - export interface ICardProductLimit { type: CardLimitType currency: string @@ -201,13 +182,7 @@ export interface ICardLimitResponse { isDisabled: boolean } -export type CloseCardReason = - | 'IssuerRequestGeneral' - | 'IssuerRequestFraud' - | 'IssuerRequestLegal' - | 'IssuerRequestIncorrectOpening' - | 'UserRequest' - | 'IssuerRequestCustomerDeceased' +export type CloseCardReason = 'UserRequest' export interface ICreateCardRequest { nameOnCard: string diff --git a/packages/wallet/backend/src/card/validation.ts b/packages/wallet/backend/src/card/validation.ts index 647eaed2d..cbf09590c 100644 --- a/packages/wallet/backend/src/card/validation.ts +++ b/packages/wallet/backend/src/card/validation.ts @@ -1,11 +1,5 @@ import { z } from 'zod' -export const getCardsByCustomerSchema = z.object({ - params: z.object({ - customerId: z.string() - }) -}) - export const getCardDetailsSchema = z.object({ params: z.object({ cardId: z.string() @@ -105,18 +99,8 @@ export const permanentlyBlockCardSchema = z.object({ params: z.object({ cardId: z.string() }), - query: z.object({ - reasonCode: z.enum([ - 'LostCard', - 'StolenCard', - 'IssuerRequestGeneral', - 'IssuerRequestFraud', - 'IssuerRequestLegal', - 'IssuerRequestIncorrectOpening', - 'CardDamagedOrNotWorking', - 'UserRequest', - 'IssuerRequestCustomerDeceased', - 'ProductDoesNotRenew' - ]) + body: z.object({ + password: z.string().min(1), + reasonCode: z.enum(['UserRequest']) }) }) diff --git a/packages/wallet/backend/src/config/env.ts b/packages/wallet/backend/src/config/env.ts index 7618f47f7..5bdc231f9 100644 --- a/packages/wallet/backend/src/config/env.ts +++ b/packages/wallet/backend/src/config/env.ts @@ -46,7 +46,9 @@ const envSchema = z.object({ SEND_EMAIL: z .enum(['true', 'false']) .default('false') - .transform((value) => value === 'true') + .transform((value) => value === 'true'), + CARD_DATA_HREF: z.string().default('UPDATEME'), + CARD_PIN_HREF: z.string().default('UPDATEME') }) export type Env = z.infer diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index 385bf8c15..d00bf83ea 100644 --- a/packages/wallet/backend/src/gatehub/client.ts +++ b/packages/wallet/backend/src/gatehub/client.ts @@ -44,7 +44,6 @@ import { BadRequest } from '@shared/backend' import { ICardDetailsResponse, ILinksResponse, - ICardResponse, ICreateCustomerRequest, ICreateCustomerResponse, ICardProductResponse, @@ -57,6 +56,7 @@ import { CloseCardReason } from '@/card/types' import { BlockReasonCode } from '@wallet/shared/src' +import { ICardResponse } from '@wallet/shared' export class GateHubClient { private supportedAssetCodes: string[] @@ -234,10 +234,15 @@ export class GateHubClient { return response } - async getUserState(userId: string): Promise { - const url = `${this.apiUrl}/id/v1/users/${userId}` + async getUserState(managedUserUuid: string): Promise { + const url = `${this.apiUrl}/id/v1/users/${managedUserUuid}` - const response = await this.request('GET', url) + const response = await this.request( + 'GET', + url, + undefined, + { managedUserUuid } + ) return response } @@ -448,21 +453,30 @@ export class GateHubClient { }) } - async getCardsByCustomer(customerId: string): Promise { - const url = `${this.apiUrl}/v1/customers/${customerId}/cards` - return this.request('GET', url) + async getCardsByCustomer( + customerId: string, + managedUserUuid: string + ): Promise { + const url = `${this.apiUrl}/cards/v1/customers/${customerId}/cards` + + return this.request('GET', url, undefined, { + managedUserUuid, + cardAppId: this.env.GATEHUB_CARD_APP_ID + }) } async getCardDetails( + managedUserUuid: string, requestBody: ICardDetailsRequest ): Promise { - const url = `${this.apiUrl}/token/card-data` + const url = `${this.apiUrl}/cards/v1/token/card-data` const response = await this.request( 'POST', url, JSON.stringify(requestBody), { + managedUserUuid, cardAppId: this.env.GATEHUB_CARD_APP_ID } ) @@ -472,19 +486,27 @@ export class GateHubClient { throw new Error('Failed to obtain token for card data retrieval') } - // TODO change this to direct call to card managing entity - // Will get this from the GateHub proxy for now - const cardDetailsUrl = `${this.apiUrl}/v1/proxy/clientDevice/cardData` - const cardDetailsResponse = await this.request( - 'GET', - cardDetailsUrl, - undefined, - { - token - } - ) + const res = await fetch(this.env.CARD_DATA_HREF, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` } + }) + // const cardDetailsUrl = `${this.apiUrl}/cards/v1/proxy/clientDevice/cardData` + // const cardDetailsResponse = await this.request( + // 'GET', + // cardDetailsUrl, + // undefined, + // { + // managedUserUuid, + // token + // } + // ) + // return cardDetailsResponse + if (!res.ok) { + throw new Error('Could not fetch card details') + } + const cardData = (await res.json()) as ICardDetailsResponse - return cardDetailsResponse + return cardData } async getCardTransactions( @@ -535,15 +557,16 @@ export class GateHubClient { } async getPin( + managedUserUuid: string, requestBody: ICardDetailsRequest ): Promise { - const url = `${this.apiUrl}/token/pin` - + const url = `${this.apiUrl}/cards/v1/token/pin` const response = await this.request( 'POST', url, JSON.stringify(requestBody), { + managedUserUuid, cardAppId: this.env.GATEHUB_CARD_APP_ID } ) @@ -553,29 +576,39 @@ export class GateHubClient { throw new Error('Failed to obtain token for card pin retrieval') } - // TODO change this to direct call to card managing entity - // Will get this from the GateHub proxy for now - const cardPinUrl = `${this.apiUrl}/v1/proxy/clientDevice/pin` - const cardPinResponse = await this.request( - 'GET', - cardPinUrl, - undefined, - { - token - } - ) + const resp = await fetch(this.env.CARD_PIN_HREF, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` } + }) - return cardPinResponse + const res = await resp.json() + + return res + // const cardPinUrl = `${this.apiUrl}/v1/proxy/clientDevice/pin` + // const cardPinResponse = await this.request( + // 'GET', + // cardPinUrl, + // undefined, + // { + // token + // } + // ) + + // return cardPinResponse } - async getTokenForPinChange(cardId: string): Promise { - const url = `${this.apiUrl}/token/pin-change` + async getTokenForPinChange( + managedUserUuid: string, + cardId: string + ): Promise { + const url = `${this.apiUrl}/cards/v1/token/pin-change` const response = await this.request( 'POST', url, JSON.stringify({ cardId: cardId }), { + managedUserUuid, cardAppId: this.env.GATEHUB_CARD_APP_ID } ) @@ -589,32 +622,57 @@ export class GateHubClient { } async changePin(token: string, cypher: string): Promise { - // TODO change this to direct call to card managing entity - // Will get this from the GateHub proxy for now - const cardPinUrl = `${this.apiUrl}/v1/proxy/clientDevice/pin` - await this.request( - 'POST', - cardPinUrl, - JSON.stringify({ cypher: cypher }), - { - token + const response = await fetch(this.env.CARD_PIN_HREF, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ cypher }) + }) + + if (!response.ok) { + let info = '' + if (response.headers.get('content-type') === 'application/json') { + info = await response.json() + } else { + info = await response.text() } - ) + this.logger.error( + `ClientDevice/pin call failed with status ${response.status}: ${info}` + ) + throw new Error('Could not change the card pin. Please try again') + } + + this.logger.info('Successfully changed card pin.') + + // TODO: Move to proxy when it's fixed + // const cardPinUrl = `${this.apiUrl}/cards/v1/proxy/clientDevice/pin` + // await this.request( + // 'POST', + // cardPinUrl, + // JSON.stringify({ cypher: cypher }), + // { + // managedUserUuid, + // token + // } + // ) } async lockCard( cardId: string, + managedUserUuid: string, reasonCode: LockReasonCode, requestBody: ICardLockRequest ): Promise { - let url = `${this.apiUrl}/v1/cards/${cardId}/lock` - url += `?reasonCode=${encodeURIComponent(reasonCode)}` + const url = `${this.apiUrl}/cards/v1/cards/${cardId}/lock?reasonCode=${encodeURIComponent(reasonCode)}` return this.request( 'PUT', url, JSON.stringify(requestBody), { + managedUserUuid, cardAppId: this.env.GATEHUB_CARD_APP_ID } ) @@ -622,15 +680,17 @@ export class GateHubClient { async unlockCard( cardId: string, + managedUserUuid: string, requestBody: ICardUnlockRequest ): Promise { - const url = `${this.apiUrl}/v1/cards/${cardId}/unlock` + const url = `${this.apiUrl}/cards/v1/cards/${cardId}/unlock` return this.request( 'PUT', url, JSON.stringify(requestBody), { + managedUserUuid, cardAppId: this.env.GATEHUB_CARD_APP_ID } ) diff --git a/packages/wallet/backend/src/gatehub/service.ts b/packages/wallet/backend/src/gatehub/service.ts index a76279a12..53a072871 100644 --- a/packages/wallet/backend/src/gatehub/service.ts +++ b/packages/wallet/backend/src/gatehub/service.ts @@ -173,12 +173,14 @@ export class GateHubService { userId: string, isDefaultCardsAccount?: boolean, walletAddressName?: string, - walletAddressPublicName?: string + walletAddressPublicName?: string, + cardId?: string ): Promise<{ account: Account; walletAddress: WalletAddress }> { const account = await this.accountService.createDefaultAccount( userId, 'EUR Account', - isDefaultCardsAccount + isDefaultCardsAccount, + cardId ) if (!account) { throw new Error('Failed to create account for managed user') @@ -252,19 +254,27 @@ export class GateHubService { const gateHubUser = existingManagedUsers.find( (gateHubUser) => gateHubUser.email === userEmail ) + if (!gateHubUser) { + throw new Error(`GateHub user with email ${userEmail} not found`) + } + + const customerId = gateHubUser.meta.meta.customerId + const cardWalletAddress = gateHubUser.meta.meta.paymentPointer + + const walletAddressName = cardWalletAddress.split('$ilp.dev/')[1] || '' + const cards = await this.gateHubClient.getCardsByCustomer( + customerId, + gateHubUser.id + ) - const walletAddressName = - gateHubUser!.meta.meta.paymentPointer.split('$ilp.dev/')[1] || '' await this.createDefaultAccountAndWAForManagedUser( userId, true, walletAddressName, - walletAddressPublicName + walletAddressPublicName, + cards[0].id ) - const customerId = gateHubUser!.meta.meta.customerId - const cardWalletAddress = gateHubUser!.meta.meta.paymentPointer - await User.query().findById(userId).patch({ customerId, cardWalletAddress diff --git a/packages/wallet/backend/src/middleware/isAuth.ts b/packages/wallet/backend/src/middleware/isAuth.ts index 6c5106d9c..49b2c5ab0 100644 --- a/packages/wallet/backend/src/middleware/isAuth.ts +++ b/packages/wallet/backend/src/middleware/isAuth.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from 'express' import { Unauthorized } from '@shared/backend' +import { User } from '@/user/model' const KYCRoutes = ['/iframe-urls/onboarding', '/gatehub/add-user-to-gateway'] @@ -14,11 +15,15 @@ export const isAuth = async ( throw new Unauthorized('Unauthorized') } - if ( - !KYCRoutes.includes(req.url) && - (req.session.user.needsWallet || req.session.user.needsIDProof) - ) { - throw new Unauthorized('Unauthorized') + if (!KYCRoutes.includes(req.url) && req.session.user.needsIDProof) { + const user = await User.query().findById(req.session.user.id) + if (user?.kycVerified) { + req.session.user.needsIDProof = false + req.session.user.customerId = user.customerId + await req.session.save() + } else { + throw new Unauthorized('Unauthorized') + } } } catch (e) { next(e) diff --git a/packages/wallet/backend/src/middleware/withSession.ts b/packages/wallet/backend/src/middleware/withSession.ts index 5c61e5eda..5e1eaf946 100644 --- a/packages/wallet/backend/src/middleware/withSession.ts +++ b/packages/wallet/backend/src/middleware/withSession.ts @@ -6,13 +6,19 @@ import { getIronSession } from 'iron-session' +let domain = env.RAFIKI_MONEY_FRONTEND_HOST + +if (env.NODE_ENV === 'production' && env.GATEHUB_ENV === 'production') { + domain = 'interledger.cards' +} + export const SESSION_OPTIONS: SessionOptions = { password: env.COOKIE_PASSWORD, cookieName: env.COOKIE_NAME, cookieOptions: { secure: env.NODE_ENV === 'production', sameSite: env.NODE_ENV === 'production' ? 'none' : 'lax', - domain: env.RAFIKI_MONEY_FRONTEND_HOST, + domain, httpOnly: true }, ttl: env.COOKIE_TTL diff --git a/packages/wallet/backend/src/reorder.ts b/packages/wallet/backend/src/reorder.ts index 631e9dba8..22e30932e 100644 --- a/packages/wallet/backend/src/reorder.ts +++ b/packages/wallet/backend/src/reorder.ts @@ -59,6 +59,7 @@ async function reorder() { await gateHubClient.closeCard( userUuid, cardId, + // @ts-expect-error we know 'IssuerRequestIncorrectOpening' ) @@ -77,7 +78,10 @@ async function reorder() { `Created card with cardId: ${card.id}; customerId: ${card.customerId}` ) - await gateHubClient.orderPlasticForCard(userUuid, card.id) + await gateHubClient.orderPlasticForCard( + '32c471ae-f7d3-4ca9-ac95-68345013d1d4', + '24BFDC8415D73F4CE0634701650AB9E6' + ) logger.info( `Ordered plastic card for user: ${userUuid}; new card id: ${card.id}` diff --git a/packages/wallet/backend/src/user/controller.ts b/packages/wallet/backend/src/user/controller.ts index 74559e561..6c131989f 100644 --- a/packages/wallet/backend/src/user/controller.ts +++ b/packages/wallet/backend/src/user/controller.ts @@ -40,6 +40,7 @@ export class UserController implements IUserController { if (req.session.user.needsIDProof && user.kycVerified) { req.session.user.needsIDProof = false + req.session.user.customerId = user.customerId await req.session.save() } diff --git a/packages/wallet/backend/tests/cards/controller.test.ts b/packages/wallet/backend/tests/cards/controller.test.ts index ef6f6ba12..57db2a703 100644 --- a/packages/wallet/backend/tests/cards/controller.test.ts +++ b/packages/wallet/backend/tests/cards/controller.test.ts @@ -5,12 +5,11 @@ import { MockResponse } from 'node-mocks-http' import { CardController } from '@/card/controller' -import { BadRequest } from '@shared/backend' +import { BadRequest, InternalServerError } from '@shared/backend' import { ICardDetailsResponse, ICardLimitRequest, - ICardLimitResponse, - ICardResponse + ICardLimitResponse } from '@/card/types' import { IGetTransactionsResponse } from '@wallet/shared/src' import { AwilixContainer } from 'awilix' @@ -27,6 +26,7 @@ import { truncateTables } from '@shared/backend/tests' import { mockLogInRequest } from '../mocks' import { createUser } from '../helpers' import { User } from '@/user/model' +import { ICardResponse } from '@wallet/shared' describe('CardController', () => { let bindings: AwilixContainer @@ -49,7 +49,7 @@ describe('CardController', () => { getPin: jest.fn(), getTokenForPinChange: jest.fn(), changePin: jest.fn(), - permanentlyBlockCard: jest.fn() + closeCard: jest.fn() } const args = mockLogInRequest().body @@ -67,7 +67,7 @@ describe('CardController', () => { email: user.email, needsWallet: !user.gateHubUserId, needsIDProof: !user.kycVerified, - customerId: user.customerId + customerId: user.customerId || 'customer-id' } req.params.cardId = 'test-card-id' @@ -119,17 +119,17 @@ describe('CardController', () => { expiryDate: '0929', customerId: 'customer-id', customerSourceId: 'a5aba6c7-b8ad-4cfe-98d5-497366a4ee2c', - productCode: 'VMDTKPREB' + productCode: 'VMDTKPREB', + isPinSet: false } ] mockCardService.getCardsByCustomer.mockResolvedValue(mockedCards) - req.params.customerId = 'customer-id' - await cardController.getCardsByCustomer(req, res, next) expect(mockCardService.getCardsByCustomer).toHaveBeenCalledWith( + req.session.user.id, 'customer-id' ) expect(res.statusCode).toBe(200) @@ -143,7 +143,7 @@ describe('CardController', () => { it('should return 400 if customerId is missing', async () => { const next = jest.fn() - delete req.params.customerId + delete req.session.user.customerId await cardController.getCardsByCustomer(req, res, (err) => { next(err) @@ -155,9 +155,8 @@ describe('CardController', () => { expect(next).toHaveBeenCalled() const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) + expect(error).toBeInstanceOf(InternalServerError) + expect(res.statusCode).toBe(500) }) }) @@ -166,6 +165,7 @@ describe('CardController', () => { const next = jest.fn() req.query = { publicKeyBase64: 'test-public-key' } + req.params = { cardId: 'test-card-id' } const mockedCardDetails: ICardDetailsResponse = { cipher: 'encrypted-card-data' @@ -177,7 +177,7 @@ describe('CardController', () => { expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { cardId: 'test-card-id', - publicKeyBase64: 'test-public-key' + publicKey: 'test-public-key' }) expect(res.statusCode).toBe(200) expect(res._getJSONData()).toEqual({ @@ -207,7 +207,7 @@ describe('CardController', () => { expect(res.statusCode).toBe(400) }) - it('should return 400 if publicKeyBase64 is missing', async () => { + it('should return 400 if publicKey is missing', async () => { const next = jest.fn() req.params.cardId = 'test-card-id' @@ -529,7 +529,7 @@ describe('CardController', () => { expect(mockCardService.getPin).toHaveBeenCalledWith(userId, { cardId: 'test-card-id', - publicKeyBase64: 'test-public-key' + publicKey: 'test-public-key' }) expect(res.statusCode).toBe(200) expect(res._getJSONData()).toEqual({ @@ -559,7 +559,7 @@ describe('CardController', () => { expect(res.statusCode).toBe(400) }) - it('should return 400 if publicKeyBase64 is missing', async () => { + it('should return 400 if publicKey is missing', async () => { const next = jest.fn() req.params.cardId = 'test-card-id' @@ -645,8 +645,7 @@ describe('CardController', () => { expect(res.statusCode).toBe(201) expect(res._getJSONData()).toEqual({ success: true, - message: 'SUCCESS', - result: {} + message: 'SUCCESS' }) }) @@ -872,23 +871,22 @@ describe('CardController', () => { it('should get block card successfully', async () => { const next = jest.fn() - mockCardService.permanentlyBlockCard.mockResolvedValue({}) + mockCardService.closeCard.mockResolvedValue({}) req.params = { cardId: 'test-card-id' } - req.query = { reasonCode: 'StolenCard' } + req.body = { reasonCode: 'UserRequest', password: args.password } - await cardController.permanentlyBlockCard(req, res, next) + await cardController.closeCard(req, res, next) - expect(mockCardService.permanentlyBlockCard).toHaveBeenCalledWith( + expect(mockCardService.closeCard).toHaveBeenCalledWith( userId, 'test-card-id', - 'StolenCard' + 'UserRequest' ) expect(res.statusCode).toBe(200) expect(res._getJSONData()).toEqual({ success: true, - message: 'SUCCESS', - result: {} + message: 'SUCCESS' }) }) it('should return 400 if reasonCode is invalid', async () => { @@ -897,7 +895,7 @@ describe('CardController', () => { req.params = { cardId: 'test-card-id' } req.query = { reasonCode: 'InvalidCode' } - await cardController.permanentlyBlockCard(req, res, (err) => { + await cardController.closeCard(req, res, (err) => { next(err) res.status(err.statusCode).json({ success: false, diff --git a/packages/wallet/frontend/next.config.js b/packages/wallet/frontend/next.config.js index a84ebbefa..9e98bb8b9 100644 --- a/packages/wallet/frontend/next.config.js +++ b/packages/wallet/frontend/next.config.js @@ -14,7 +14,6 @@ if ( /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', - reactStrictMode: true, poweredByHeader: false, env: { NEXT_PUBLIC_BACKEND_URL: @@ -23,7 +22,8 @@ const nextConfig = { process.env.NEXT_PUBLIC_OPEN_PAYMENTS_HOST || '$rafiki-backend/', NEXT_PUBLIC_AUTH_HOST: process.env.NEXT_PUBLIC_AUTH_HOST || 'http://localhost:3006', - NEXT_PUBLIC_THEME: process.env.NEXT_PUBLIC_THEME || 'dark', + NEXT_PUBLIC_THEME: process.env.NEXT_PUBLIC_THEME || 'light', + NEXT_PUBLIC_GATEHUB_ENV: process.env.NEXT_PUBLIC_GATEHUB_ENV || 'sandbox', NEXT_PUBLIC_FEATURES_ENABLED } } diff --git a/packages/wallet/frontend/package.json b/packages/wallet/frontend/package.json index a8447da31..71cda240d 100644 --- a/packages/wallet/frontend/package.json +++ b/packages/wallet/frontend/package.json @@ -11,11 +11,13 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", + "@types/node-rsa": "^1.1.4", "@wallet/shared": "workspace:*", "class-variance-authority": "^0.7.0", "ky": "^1.7.2", "next": "14.2.10", "next-themes": "^0.3.0", + "node-rsa": "^1.1.1", "nprogress": "^0.2.0", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/packages/wallet/frontend/src/components/dialogs/TerminateCardDialog.tsx b/packages/wallet/frontend/src/components/dialogs/TerminateCardDialog.tsx new file mode 100644 index 000000000..891e423a6 --- /dev/null +++ b/packages/wallet/frontend/src/components/dialogs/TerminateCardDialog.tsx @@ -0,0 +1,143 @@ +import type { DialogProps } from '@/lib/types/dialog' +import { Dialog, Transition } from '@headlessui/react' +import { Fragment } from 'react' +import { Button } from '@/ui/Button' +import { useDialog } from '@/lib/hooks/useDialog' +import { useZodForm } from '@/lib/hooks/useZodForm' +import { Form } from '@/ui/forms/Form' +import { UserCardFront } from '../userCards/UserCard' +import { cardService, terminateCardSchema } from '@/lib/api/card' +import { useToast } from '@/lib/hooks/useToast' +import { getObjectKeys } from '@/utils/helpers' +import { ICardResponse } from '@wallet/shared' +import { Input } from '@/ui/forms/Input' +import { useRouter } from 'next/router' + +type TerminateCardDialogProps = Pick & { + card: ICardResponse +} + +export const TerminateCardDialog = ({ + onClose, + card +}: TerminateCardDialogProps) => { + const router = useRouter() + const [, closeDialog] = useDialog() + const { toast } = useToast() + const terminateCardForm = useZodForm({ + schema: terminateCardSchema, + defaultValues: { + reason: { value: 'UserRequest', label: 'asda' } + } + }) + + return ( + + + +
+ +
+
+ + + + Terminate Card + +
+
{ + const response = await cardService.terminate( + card.id, + data + ) + + if (response.success) { + closeDialog() + toast({ + description: 'Card has been successfully terminated.', + variant: 'success' + }) + router.replace(router.pathname) + } else { + const { errors, message } = response + if (errors) { + getObjectKeys(errors).map((field) => + terminateCardForm.setError(field, { + message: errors[field] + }) + ) + } + if (message) { + terminateCardForm.setError('root', { message }) + } + } + }} + > +
+ +

+ Are you sure you want to terminate?
You + won't be able to use this card again.{' '} +

+
+ +
+ + +
+
+
+
+
+
+
+
+
+ ) +} diff --git a/packages/wallet/frontend/src/components/dialogs/UserCardPINDialog.tsx b/packages/wallet/frontend/src/components/dialogs/UserCardPINDialog.tsx index f89ea3e30..cd2dff31a 100644 --- a/packages/wallet/frontend/src/components/dialogs/UserCardPINDialog.tsx +++ b/packages/wallet/frontend/src/components/dialogs/UserCardPINDialog.tsx @@ -5,25 +5,47 @@ import { Transition, TransitionChild } from '@headlessui/react' -import { Fragment, useState } from 'react' +import { Fragment, useEffect, useState } from 'react' import type { DialogProps } from '@/lib/types/dialog' -import { cardServiceMock, changePinSchema, IUserCard } from '@/lib/api/card' import { UserCardFront } from '@/components/userCards/UserCard' -import { Button } from '@/ui/Button' -import { useZodForm } from '@/lib/hooks/useZodForm' -import { Form } from '@/ui/forms/Form' -import { useRouter } from 'next/router' -import { getObjectKeys } from '@/utils/helpers' -import { Input } from '@/ui/forms/Input' +import { ICardResponse } from '@wallet/shared' type UserCardPINDialogProos = Pick & { - card: IUserCard + card: ICardResponse + pin: string } export const UserCardPINDialog = ({ card, + pin, onClose }: UserCardPINDialogProos) => { + // Initial time in seconds (1 hour) + const initialTime = 10 + const [timeRemaining, setTimeRemaining] = useState(initialTime) + + useEffect(() => { + const timerInterval = setInterval(() => { + setTimeRemaining((prevTime) => { + if (prevTime === 0) { + clearInterval(timerInterval) + return 0 + } else { + return prevTime - 1 + } + }) + }, 1000) + + return () => clearInterval(timerInterval) + }, []) + + useEffect(() => { + const timer = setTimeout(() => { + onClose() + }, initialTime * 1000) + return () => clearTimeout(timer) + }, [onClose]) + return ( @@ -52,21 +74,23 @@ export const UserCardPINDialog = ({ > - Card PIN + Here is your card PIN -
+
-

Physical Debit Card

-

- {card.number} +

+ {pin}

- +

+ Dialog will close in {timeRemaining} second(s). +

@@ -76,70 +100,70 @@ export const UserCardPINDialog = ({ ) } -const ChangePinForm = () => { - const [showForm, setShowForm] = useState(false) - const router = useRouter() - const form = useZodForm({ - schema: changePinSchema - }) - - if (!showForm) { - return ( - - ) - } - return ( -
{ - const response = await cardServiceMock.changePin(data) - - if (response.success) { - router.replace(router.asPath) - } else { - const { errors, message } = response - form.setError('root', { - message - }) - if (errors) { - getObjectKeys(errors).map((field) => - form.setError(field, { - message: errors[field] - }) - ) - } - } - }} - > - - - -
- ) -} +// const ChangePinForm = () => { +// const [showForm, setShowForm] = useState(false) +// const router = useRouter() +// const form = useZodForm({ +// schema: changePinSchema +// }) +// +// if (!showForm) { +// return ( +// +// ) +// } +// return ( +//
{ +// const response = await cardServiceMock.changePin(data) +// +// if (response.success) { +// router.replace(router.asPath) +// } else { +// const { errors, message } = response +// form.setError('root', { +// message +// }) +// if (errors) { +// getObjectKeys(errors).map((field) => +// form.setError(field, { +// message: errors[field] +// }) +// ) +// } +// } +// }} +// > +// +// +// +//
+// ) +// } diff --git a/packages/wallet/frontend/src/components/dialogs/UserCardSpendingLimitDialog.tsx b/packages/wallet/frontend/src/components/dialogs/UserCardSpendingLimitDialog.tsx index 38b040948..faaca1d53 100644 --- a/packages/wallet/frontend/src/components/dialogs/UserCardSpendingLimitDialog.tsx +++ b/packages/wallet/frontend/src/components/dialogs/UserCardSpendingLimitDialog.tsx @@ -8,7 +8,7 @@ import { import { Fragment } from 'react' import type { DialogProps } from '@/lib/types/dialog' import { - cardServiceMock, + cardService, dailySpendingLimitSchema, monthlySpendingLimitSchema } from '@/lib/api/card' @@ -78,7 +78,7 @@ const DailySpendingLimitForm = () => {
{ - const response = await cardServiceMock.setDailySpendingLimit(data) + const response = await cardService.setDailySpendingLimit(data) if (response.success) { router.replace(router.asPath) @@ -134,7 +134,7 @@ const MonthlySpendingLimitForm = () => { { - const response = await cardServiceMock.setMonthlySpendingLimit(data) + const response = await cardService.setMonthlySpendingLimit(data) if (response.success) { router.replace(router.asPath) diff --git a/packages/wallet/frontend/src/components/userCards/UserCard.tsx b/packages/wallet/frontend/src/components/userCards/UserCard.tsx index 0882c4a24..37e99c153 100644 --- a/packages/wallet/frontend/src/components/userCards/UserCard.tsx +++ b/packages/wallet/frontend/src/components/userCards/UserCard.tsx @@ -1,11 +1,25 @@ import { useState, type ComponentProps } from 'react' import { CopyButton } from '@/ui/CopyButton' import { Chip, GateHubLogo, MasterCardLogo } from '../icons/UserCardIcons' -import { cn } from '@/utils/helpers' -import type { IUserCard } from '@/lib/api/card' -import { useCardContext, UserCardContext } from './UserCardContext' +import { cn, parseJwt } from '@/utils/helpers' +import { + ICardData, + isLockedCard, + KeysProvider, + useCardContext, + UserCardContext +} from './UserCardContext' import { UserCardActions } from './UserCardActions' import { UserCardSettings } from './UserCardSettings' +import { ICardResponse } from '@wallet/shared' +import { Form } from '@/ui/forms/Form' +import { cardService, changePinSchema } from '@/lib/api/card' +import { Button } from '@/ui/Button' +import { useZodForm } from '@/lib/hooks/useZodForm' +import { useRouter } from 'next/router' +import crypto from 'crypto' +import { Input } from '@/ui/forms/Input' +import { useToast } from '@/lib/hooks/useToast' export type UserCardContainerProps = ComponentProps<'div'> @@ -28,13 +42,15 @@ const UserCardContainer = ({ } interface UserCardFrontProps extends ComponentProps<'div'> { - card: IUserCard + nameOnCard: ICardResponse['nameOnCard'] + isBlocked: boolean } // Even if the UserCard lives inside the context we explicitly pass the card // details as a prop, since we have to use this component in dialogs as well. export const UserCardFront = ({ - card, + nameOnCard, + isBlocked, className, ...props }: UserCardFrontProps) => { @@ -43,7 +59,7 @@ export const UserCardFront = ({
@@ -54,11 +70,11 @@ export const UserCardFront = ({
- {card.name} + {nameOnCard}
- {card.isFrozen ? ( + {isBlocked ? (
) : null} @@ -66,7 +82,11 @@ export const UserCardFront = ({ } const UserCardBack = () => { - const { card } = useCardContext() + const { cardData } = useCardContext() + + if (!cardData) { + return null + } return ( @@ -78,29 +98,29 @@ const UserCardBack = () => { Card Number

-

{card.number}

+

{cardData.Pan}

Expiry

-

{card.expiry}

+

{`${cardData.ExpiryDate.substring(0, 2)}/${cardData.ExpiryDate.substring(2, 4)}`}

CVV

-

{card.cvv}

+

{cardData.Cvc2}

@@ -111,24 +131,155 @@ const UserCardBack = () => { } interface UserCardProps { - card: IUserCard + card: ICardResponse } + export const UserCard = ({ card }: UserCardProps) => { const [showDetails, setShowDetails] = useState(false) + const [cardData, setCardData] = useState(null) + + if (card.status === 'SoftDelete') { + return <>Your card has been terminated. + } + + const isBlocked = isLockedCard(card) return ( - -
-
- {card.isFrozen ? : null} - {!card.isFrozen && showDetails ? : null} - {!card.isFrozen && !showDetails ? ( - - ) : null} - -
- -
+ + + {card.isPinSet ? ( +
+
+ {isBlocked ? ( + + ) : null} + {!isBlocked && showDetails ? : null} + {!isBlocked && !showDetails ? ( + + ) : null} + +
+ +
+ ) : ( + + )} +
) } + +const SetPinForm = () => { + const router = useRouter() + const { card } = useCardContext() + const { toast } = useToast() + const form = useZodForm({ + schema: changePinSchema + }) + + return ( + { + const response = await cardService.getChangePinToken(card.id) + + if (!response.success) { + toast({ + description: + 'Could not get details for change PIN. Please try again.', + variant: 'error' + }) + console.error(response.message) + return + } + + if (!response.result) { + toast({ + description: + 'Could not get details for change PIN. Please try again.', + variant: 'error' + }) + console.error(response.message) + return + } + + const token = response.result + + const { publicKey } = parseJwt(token) as { + publicKey: string + } + + const pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----` + + const buf = Buffer.from(data.pin, 'utf8') + const cypher = crypto + .publicEncrypt( + { + key: pemPublicKey, + padding: crypto.constants.RSA_PKCS1_PADDING + }, + buf + ) + .toString('base64') + + const res = await cardService.changePin(card.id, { + token, + cypher + }) + + if (!res.success) { + toast({ + description: 'Could not change PIN for card. Please try again.', + variant: 'error' + }) + console.error(response.message) + return + } + + toast({ + description: 'Card PIN was successfully set.', + variant: 'success' + }) + router.replace(router.asPath) + }} + > +

Set PIN

+ + + + + ) +} diff --git a/packages/wallet/frontend/src/components/userCards/UserCardActions.tsx b/packages/wallet/frontend/src/components/userCards/UserCardActions.tsx index d11eaeeba..c9724f021 100644 --- a/packages/wallet/frontend/src/components/userCards/UserCardActions.tsx +++ b/packages/wallet/frontend/src/components/userCards/UserCardActions.tsx @@ -1,11 +1,23 @@ import { Button } from '@/ui/Button' import { Eye, EyeCross, Snow, Trash } from '../icons/CardButtons' -import { useCardContext } from './UserCardContext' -import { cardServiceMock } from '@/lib/api/card' +import { + ICardData, + isLockedCard, + useCardContext, + useKeysContext +} from './UserCardContext' +import { cardService } from '@/lib/api/card' import { useRouter } from 'next/router' +import { useToast } from '@/lib/hooks/useToast' +import NodeRSA from 'node-rsa' +import { useDialog } from '@/lib/hooks/useDialog' +import { TerminateCardDialog } from '../dialogs/TerminateCardDialog' export const FrozenCardActions = () => { const router = useRouter() + const { card } = useCardContext() + const { toast } = useToast() + const [openDialog, closeDialog] = useDialog() return ( <> @@ -15,18 +27,22 @@ export const FrozenCardActions = () => { aria-label="unfreeze" className="group" onClick={async () => { - // Maybe use toats for showcasing the result of the api calls, - // specifically for card actions? - // We will probably have a lot more dialogs for card settings - // and using dialogs again for showing the response might be a bit - // cumbersome. - const response = await cardServiceMock.unfreeze() + const response = await cardService.unfreeze(card.id) if (!response.success) { - console.error('[TODO] UPDATE ME - error while unfreezing card') + toast({ + description: 'Could not unfreeze card. Please try again', + variant: 'error' + }) + console.error(response.message) + return } if (response.success) { + toast({ + description: 'Card was successfully unfrozen.', + variant: 'success' + }) router.replace(router.asPath) } }} @@ -43,20 +59,9 @@ export const FrozenCardActions = () => { aria-label="terminate card" className="group" onClick={async () => { - // Maybe use toats for showcasing the result of the api calls, - // specifically for card actions? - // We will probably have a lot more dialogs for card settings - // and using dialogs again for showing the response might be a bit - // cumbersome. - const response = await cardServiceMock.terminate() - - if (!response.success) { - console.error('[TODO] UPDATE ME - error while terminating card') - } - - if (response.success) { - router.replace(router.asPath) - } + openDialog( + + ) }} >
@@ -71,7 +76,9 @@ export const FrozenCardActions = () => { const DefaultCardActions = () => { const router = useRouter() - const { showDetails, setShowDetails } = useCardContext() + const { card, showDetails, setShowDetails, setCardData } = useCardContext() + const { keys } = useKeysContext() + const { toast } = useToast() return ( <> @@ -81,18 +88,24 @@ const DefaultCardActions = () => { aria-label="freeze" className="group" onClick={async () => { - // Maybe use toats for showcasing the result of the api calls, - // specifically for card actions? - // We will probably have a lot more dialogs for card settings - // and using dialogs again for showing the response might be a bit - // cumbersome. - const response = await cardServiceMock.freeze() + const response = await cardService.freeze(card.id) if (!response.success) { - console.error('[TODO] UPDATE ME - error while freezing card') + toast({ + description: 'Could not freeze card. Please try again', + variant: 'error' + }) + console.error(response.message) + return } if (response.success) { + toast({ + description: 'Card was successfully frozen.', + variant: 'success' + }) + setCardData(null) + setShowDetails(false) router.replace(router.asPath) } }} @@ -108,7 +121,55 @@ const DefaultCardActions = () => { intent="secondary" aria-label={showDetails ? 'hide details' : 'show details'} className="group" - onClick={() => setShowDetails((prev) => !prev)} + onClick={async () => { + if (showDetails) { + setShowDetails(false) + setCardData(null) + return + } + if (!keys) { + await router.replace(router.pathname) + return + } + + const response = await cardService.getCardData(card.id, { + publicKeyBase64: keys.publicKey + }) + + if (!response.success) { + toast({ + description: 'Could not fetch card details. Please try again', + variant: 'error' + }) + return + } + + if (!response.result) { + toast({ + description: 'Could not fetch card details. Please try again', + variant: 'error' + }) + return + } + + // TODO: Move this to SubtleCrypto + const privateKey = new NodeRSA(keys.privateKey) + privateKey.setOptions({ + encryptionScheme: 'pkcs1', + environment: 'browser' + }) + + const decryptedRequestData = privateKey + .decrypt(response.result.cypher) + .toString('utf8') + + const cardData = JSON.parse(decryptedRequestData) as ICardData + cardData.Pan = formatCardPan(cardData.Pan) + + setCardData(cardData) + + setShowDetails(true) + }} >
{showDetails ? ( @@ -128,10 +189,15 @@ const DefaultCardActions = () => { export const UserCardActions = () => { const { card } = useCardContext() + const isLocked = isLockedCard(card) return (
- {card.isFrozen ? : } + {isLocked ? : }
) } + +function formatCardPan(pan: string): string { + return pan.replace(/(\d{4})(?=\d)/g, '$1 ').trim() +} diff --git a/packages/wallet/frontend/src/components/userCards/UserCardContext.tsx b/packages/wallet/frontend/src/components/userCards/UserCardContext.tsx index 4b1b067aa..9cdca1dec 100644 --- a/packages/wallet/frontend/src/components/userCards/UserCardContext.tsx +++ b/packages/wallet/frontend/src/components/userCards/UserCardContext.tsx @@ -1,15 +1,28 @@ -import { IUserCard } from '@/lib/api/card' +import { ab2str } from '@/utils/helpers' +import { ICardResponse } from '@wallet/shared' +import { useRouter } from 'next/router' import { createContext, + ReactNode, useContext, + useEffect, + useState, type Dispatch, type SetStateAction } from 'react' +export interface ICardData { + Pan: string + ExpiryDate: string + Cvc2: string +} + interface UserCardContextValue { showDetails: boolean setShowDetails: Dispatch> - card: IUserCard + card: ICardResponse + cardData: ICardData | null + setCardData: Dispatch> } export const UserCardContext = createContext({} as UserCardContextValue) @@ -25,3 +38,89 @@ export const useCardContext = () => { return cardContext } + +export interface Keys { + publicKey: string + privateKey: string +} + +type KeysContextProps = { + keys: Keys | null + setKeys: Dispatch> +} + +export const KeysContext = createContext(null) + +export const useKeysContext = () => { + const keysContext = useContext(KeysContext) + + if (!keysContext) { + throw new Error('"useKeysContext" is used outside the KeysContextProvider.') + } + + return keysContext +} + +type KeysProviderProps = { + children: ReactNode +} + +export const KeysProvider = ({ children }: KeysProviderProps) => { + const { pathname } = useRouter() + const [keys, setKeys] = useState(null) + const { setCardData, setShowDetails } = useCardContext() + + useEffect(() => { + async function generateKeyPair() { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' } + }, + true, + ['encrypt', 'decrypt'] + ) + + const exportedPrivateKey = await crypto.subtle.exportKey( + 'pkcs8', + keyPair.privateKey + ) + const privateKey = `-----BEGIN PRIVATE KEY-----\n${btoa(ab2str(exportedPrivateKey))}\n-----END PRIVATE KEY-----` + + const exportedPublicKey = await crypto.subtle.exportKey( + 'spki', + keyPair.publicKey + ) + const publicKey = btoa(ab2str(exportedPublicKey)) + + setKeys({ + publicKey, + privateKey + }) + } + + if (!keys) { + void generateKeyPair() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (pathname !== '/cards') { + setCardData(null) + setShowDetails(false) + } + }, [pathname, setCardData, setShowDetails]) + + return ( + + {children} + + ) +} + +export function isLockedCard(card: ICardResponse): boolean { + return card.lockLevel === 'Client' +} diff --git a/packages/wallet/frontend/src/components/userCards/UserCardSettings.tsx b/packages/wallet/frontend/src/components/userCards/UserCardSettings.tsx index 10349a75f..13627cecd 100644 --- a/packages/wallet/frontend/src/components/userCards/UserCardSettings.tsx +++ b/packages/wallet/frontend/src/components/userCards/UserCardSettings.tsx @@ -3,18 +3,21 @@ import { Limit } from '../icons/Limit' import { CardKey } from '../icons/Key' import { UserCardSpendingLimitDialog } from '@/components/dialogs/UserCardSpendingLimitDialog' import { UserCardPINDialog } from '@/components/dialogs/UserCardPINDialog' -import { useCardContext } from './UserCardContext' +import { useCardContext, useKeysContext } from './UserCardContext' +import { cardService } from '@/lib/api/card' +import { useToast } from '@/lib/hooks/useToast' +import NodeRSA from 'node-rsa' export const UserCardSettings = () => { return ( -
    - +
    ) } -const SpendingLimit = () => { +// Unused at the moment +export const SpendingLimit = () => { const [openDialog, closeDialog] = useDialog() return ( @@ -45,13 +48,50 @@ const SpendingLimit = () => { const PinSettings = () => { const { card } = useCardContext() + const { keys } = useKeysContext() + const { toast } = useToast() const [openDialog, closeDialog] = useDialog() + if (!keys) return null + return ( -
  • +
-
+ {FEATURES_ENABLED ? null : ( +
+ )}

Already a customer?{' '} diff --git a/packages/wallet/frontend/src/pages/card/index.tsx b/packages/wallet/frontend/src/pages/card.tsx similarity index 80% rename from packages/wallet/frontend/src/pages/card/index.tsx rename to packages/wallet/frontend/src/pages/card.tsx index f59eed365..7b1c46f30 100644 --- a/packages/wallet/frontend/src/pages/card/index.tsx +++ b/packages/wallet/frontend/src/pages/card.tsx @@ -2,8 +2,9 @@ import type { GetServerSideProps, InferGetServerSidePropsType } from 'next' import { AppLayout } from '@/components/layouts/AppLayout' import { PageHeader } from '@/components/PageHeader' import { NextPageWithLayout } from '@/lib/types/app' -import { cardServiceMock, IUserCard } from '@/lib/api/card' +import { cardService } from '@/lib/api/card' import { UserCard } from '@/components/userCards/UserCard' +import { ICardResponse } from '@wallet/shared' type UserCardPageProps = InferGetServerSidePropsType @@ -19,19 +20,20 @@ const UserCardPage: NextPageWithLayout = ({ card }) => { } export const getServerSideProps: GetServerSideProps<{ - card: IUserCard + card: ICardResponse }> = async (ctx) => { - const response = await cardServiceMock.getDetails(ctx.req.headers.cookie) + const response = await cardService.getDetails(ctx.req.headers.cookie) if (!response.success || !response.result) { return { notFound: true } } + console.log(response.result) return { props: { - card: response.result + card: response.result[0] } } } diff --git a/packages/wallet/frontend/src/pages/index.tsx b/packages/wallet/frontend/src/pages/index.tsx index f62cd09ec..7f063e144 100644 --- a/packages/wallet/frontend/src/pages/index.tsx +++ b/packages/wallet/frontend/src/pages/index.tsx @@ -77,6 +77,9 @@ export const getServerSideProps: GetServerSideProps<{ const response = await accountService.list(ctx.req.headers.cookie) const user = await userService.me(ctx.req.headers.cookie) + console.log('response', JSON.stringify(response, null, 2)) + console.log('user', JSON.stringify(user, null, 2)) + if (!response.success || !user.success) { return { notFound: true diff --git a/packages/wallet/frontend/src/pages/kyc.tsx b/packages/wallet/frontend/src/pages/kyc.tsx index e46516e33..20fa025d7 100644 --- a/packages/wallet/frontend/src/pages/kyc.tsx +++ b/packages/wallet/frontend/src/pages/kyc.tsx @@ -5,7 +5,7 @@ import { GateHubMessageType, type GateHubMessageError } from '@/lib/types/windowMessages' -import { FEATURES_ENABLED } from '@/utils/constants' +import { FEATURES_ENABLED, GATEHUB_ENV } from '@/utils/constants' import { useRouter } from 'next/router' import { GetServerSideProps, InferGetServerSidePropsType } from 'next/types' import { useEffect } from 'react' @@ -31,6 +31,7 @@ const KYCPage: NextPageWithLayout = ({ // TODO: Handle resubmitted (https://github.com/interledger/testnet/issues/1748) // https://docs.gatehub.net/api-documentation/c3OPAp5dM191CDAdwyYS/gatehub-products/gatehub-onboarding#message-events const onMessage = async (e: MessageEvent) => { + console.debug(e.data) switch (e.data.type) { case GateHubMessageType.OnboardingCompleted: // eslint-disable-next-line no-case-declarations @@ -38,12 +39,14 @@ const KYCPage: NextPageWithLayout = ({ applicantStatus: 'submitted' | 'resubmitted' } if (value.applicantStatus === 'submitted') { - await fetch(addUserToGatewayUrl, { - method: 'POST', - body: JSON.stringify(e.data, null, 2), - credentials: 'include' - }) - router.replace('/') + if (FEATURES_ENABLED && GATEHUB_ENV === 'sandbox') { + await fetch(addUserToGatewayUrl, { + method: 'POST', + body: JSON.stringify(e.data, null, 2), + credentials: 'include' + }) + router.replace('/') + } } break case GateHubMessageType.OnboardingError: diff --git a/packages/wallet/frontend/src/utils/constants.ts b/packages/wallet/frontend/src/utils/constants.ts index eeca376d3..da48c5716 100644 --- a/packages/wallet/frontend/src/utils/constants.ts +++ b/packages/wallet/frontend/src/utils/constants.ts @@ -1,5 +1,6 @@ export const OPEN_PAYMENTS_HOST = process.env.NEXT_PUBLIC_OPEN_PAYMENTS_HOST export const THEME = process.env.NEXT_PUBLIC_THEME +export const GATEHUB_ENV = process.env.NEXT_PUBLIC_GATEHUB_ENV export const FEATURES_ENABLED = process.env.NEXT_PUBLIC_FEATURES_ENABLED === 'true' ? true : false /** diff --git a/packages/wallet/frontend/src/utils/helpers.ts b/packages/wallet/frontend/src/utils/helpers.ts index 23eb7dbcb..c9afa425a 100644 --- a/packages/wallet/frontend/src/utils/helpers.ts +++ b/packages/wallet/frontend/src/utils/helpers.ts @@ -139,3 +139,23 @@ export const replaceWalletAddressProtocol = ( ? paymentPointer.replace('http://', '$') : paymentPointer } + +export function ab2str(buf: ArrayBuffer) { + //@ts-expect-error: We know + return String.fromCharCode.apply(null, new Uint8Array(buf)) +} + +export function parseJwt(token: string) { + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join('') + ) + + return JSON.parse(jsonPayload) +} diff --git a/packages/wallet/frontend/tailwind.config.js b/packages/wallet/frontend/tailwind.config.js index e45701ab4..323cb2a6c 100644 --- a/packages/wallet/frontend/tailwind.config.js +++ b/packages/wallet/frontend/tailwind.config.js @@ -45,6 +45,22 @@ module.exports = { } }, extend: { + animation: { + blink: 'blink 1.5s infinite both' + }, + keyframes: { + blink: { + '0%': { + opacity: '0.7' + }, + '50%': { + opacity: '1' + }, + '100%': { + opacity: ' 0.7' + } + } + }, boxShadow: { 'glow-button': [ '0 0 0.2rem #ffffff', diff --git a/packages/wallet/frontend/tsconfig.json b/packages/wallet/frontend/tsconfig.json index 300960571..ef7d2b62a 100644 --- a/packages/wallet/frontend/tsconfig.json +++ b/packages/wallet/frontend/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "target": "ES5", + "target": "ES2020", "module": "esnext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/packages/wallet/shared/src/types/card.ts b/packages/wallet/shared/src/types/card.ts index 11ef2baf0..637aa1573 100644 --- a/packages/wallet/shared/src/types/card.ts +++ b/packages/wallet/shared/src/types/card.ts @@ -62,3 +62,21 @@ export interface IGetTransactionsResponse { data: ITransaction[] pagination: IPagination } + +export interface ICardResponse { + sourceId: string + nameOnCard: string + productCode: string + id: string + accountId: string + accountSourceId: string + maskedPan: string + status: string + statusReasonCode: string | null + lockLevel: string | null + expiryDate: string + customerId: string + customerSourceId: string + isPinSet: boolean + walletAddress?: string +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6448e867..803744742 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -424,6 +424,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node-rsa': + specifier: ^1.1.4 + version: 1.1.4 '@wallet/shared': specifier: workspace:* version: link:../shared @@ -439,6 +442,9 @@ importers: next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + node-rsa: + specifier: ^1.1.1 + version: 1.1.1 nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -2443,6 +2449,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node-rsa@1.1.4': + resolution: {integrity: sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==} + '@types/node@18.19.44': resolution: {integrity: sha512-ZsbGerYg72WMXUIE9fYxtvfzLEuq6q8mKERdWFnqTmOvudMxnz+CBNRoOwJ2kNpFOncrKjT1hZwxjlFgQ9qvQA==} @@ -4842,6 +4851,9 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + normalize-path@2.1.1: resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} engines: {node: '>=0.10.0'} @@ -8674,6 +8686,10 @@ snapshots: '@types/mime@1.3.5': {} + '@types/node-rsa@1.1.4': + dependencies: + '@types/node': 20.14.15 + '@types/node@18.19.44': dependencies: undici-types: 5.26.5 @@ -11584,6 +11600,10 @@ snapshots: node-releases@2.0.18: {} + node-rsa@1.1.1: + dependencies: + asn1: 0.2.6 + normalize-path@2.1.1: dependencies: remove-trailing-separator: 1.1.0