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 (
+
+
+
+ )
+}
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 (
-
{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 (
+
+ )
+}
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