From 64c49c1100ccfa64b2f87467c3a9c9f6f3b0bf43 Mon Sep 17 00:00:00 2001 From: teidova21 <52156842+teidova21@users.noreply.github.com> Date: Sat, 17 Sep 2022 18:09:18 +0200 Subject: [PATCH 01/17] Update README.md (#86) Make discord picture clickable and send people to discord server invite instead of sending people to the picture. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b0490f7..f61d2c42 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC_BY--NC--SA_4.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) -![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord) +[![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord)](https://discord.gg/HYwBjTbAY5)
From ddcb002b366f0770a91c306ce1362eca0c5c2cac Mon Sep 17 00:00:00 2001 From: Anton Stjernquist <32514829+antonstjernquist@users.noreply.github.com> Date: Tue, 27 Sep 2022 19:43:52 +0200 Subject: [PATCH 02/17] fix: unload player data from NUI (#103) --- src/client/cl_events.ts | 3 ++- web/src/App.tsx | 33 +++++++++++++++++++++------------ web/src/data/transactions.ts | 9 ++++++--- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/client/cl_events.ts b/src/client/cl_events.ts index fdf8d714..796385c2 100644 --- a/src/client/cl_events.ts +++ b/src/client/cl_events.ts @@ -14,6 +14,7 @@ import { } from '@typings/Events'; import { Invoice } from '@typings/Invoice'; import { Transaction } from '@typings/Transaction'; +import { OnlineUser } from '@typings/user'; import { RegisterNuiProxy } from 'cl_utils'; import API from './cl_api'; import config from './cl_config'; @@ -84,7 +85,7 @@ onNet(Broadcasts.RemovedSharedUser, () => { SendBankUIMessage('PEFCL', Broadcasts.RemovedSharedUser, {}); }); -onNet(UserEvents.Loaded, async () => { +onNet(UserEvents.Loaded, async (user: OnlineUser) => { console.debug('Waiting for NUI to load ..'); await waitForNUILoaded(); console.debug('Loaded. Emitting data to NUI.'); diff --git a/web/src/App.tsx b/web/src/App.tsx index b0981f74..4ec4ed21 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,27 +1,30 @@ +import Devbar from '@components/DebugBar'; +import { accountsAtom } from '@data/accounts'; +import { transactionBaseAtom } from '@data/transactions'; import styled from '@emotion/styled'; +import { BroadcastsWrapper } from '@hooks/useBroadcasts'; import { useExitListener } from '@hooks/useExitListener'; +import { useNuiEvent } from '@hooks/useNuiEvent'; +import { NUIEvents, UserEvents } from '@typings/Events'; +import { fetchNui } from '@utils/fetchNui'; import dayjs from 'dayjs'; -import updateLocale from 'dayjs/plugin/updateLocale'; import 'dayjs/locale/sv'; +import updateLocale from 'dayjs/plugin/updateLocale'; +import { useSetAtom } from 'jotai'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Route } from 'react-router-dom'; import './App.css'; import { useConfig } from './hooks/useConfig'; import theme from './utils/theme'; -import Accounts from './views/accounts/Accounts'; -import Dashboard from './views/dashboard/Dashboard'; -import Invoices from './views/Invoices/Invoices'; import ATM from './views/ATM/ATM'; -import { BroadcastsWrapper } from '@hooks/useBroadcasts'; -import Transfer from './views/transfer/Transfer'; -import Transactions from './views/transactions/Transactions'; -import Devbar from '@components/DebugBar'; -import { NUIEvents, UserEvents } from '@typings/Events'; import Deposit from './views/Deposit/Deposit'; -import { fetchNui } from '@utils/fetchNui'; +import Invoices from './views/Invoices/Invoices'; import Withdraw from './views/Withdraw/Withdraw'; -import { useNuiEvent } from '@hooks/useNuiEvent'; +import Accounts from './views/accounts/Accounts'; +import Dashboard from './views/dashboard/Dashboard'; +import Transactions from './views/transactions/Transactions'; +import Transfer from './views/transfer/Transfer'; dayjs.extend(updateLocale); @@ -46,12 +49,18 @@ const Content = styled.div` const App: React.FC = () => { const config = useConfig(); + const setAccounts = useSetAtom(accountsAtom); + const setTransactions = useSetAtom(transactionBaseAtom); const [hasLoaded, setHasLoaded] = useState(process.env.NODE_ENV === 'development'); const [isAtmVisible, setIsAtmVisible] = useState(false); const [isVisible, setIsVisible] = useState(false); useNuiEvent('PEFCL', UserEvents.Loaded, () => setHasLoaded(true)); - useNuiEvent('PEFCL', UserEvents.Unloaded, () => setHasLoaded(false)); + useNuiEvent('PEFCL', UserEvents.Unloaded, () => { + setAccounts([]); + setTransactions(); + setHasLoaded(false); + }); useEffect(() => { fetchNui(NUIEvents.Loaded); diff --git a/web/src/data/transactions.ts b/web/src/data/transactions.ts index f312d670..51d07ca0 100644 --- a/web/src/data/transactions.ts +++ b/web/src/data/transactions.ts @@ -27,14 +27,17 @@ const getTransactions = async (input: GetTransactionsInput): Promise(initialState); -export const transactionBaseAtom = atom( +export const transactionBaseAtom = atom< + Promise, + GetTransactionsResponse | undefined +>( async (get) => { const hasTransactions = get(rawTransactionsAtom).transactions.length > 0; return hasTransactions ? get(rawTransactionsAtom) : await getTransactions({ ...initialState }); }, - async (get, set, by: Partial | undefined) => { + async (get, set, by?) => { const currentSettings = get(rawTransactionsAtom); - return set(rawTransactionsAtom, await getTransactions({ ...currentSettings, ...by })); + return set(rawTransactionsAtom, by ?? (await getTransactions({ ...currentSettings }))); }, ); From f761e5c8819f95fcfe1a4086b4d5735624498914 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Mon, 3 Oct 2022 00:48:07 +0200 Subject: [PATCH 03/17] fix: show transactions for shared accounts as well --- src/server/services/transaction/transaction.service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/server/services/transaction/transaction.service.ts b/src/server/services/transaction/transaction.service.ts index 0e99e236..0f3b9a9d 100644 --- a/src/server/services/transaction/transaction.service.ts +++ b/src/server/services/transaction/transaction.service.ts @@ -54,12 +54,18 @@ export class TransactionService { const user = this._userService.getUser(req.source); const accounts = await this._accountDB.getAccountsByIdentifier(user.getIdentifier()); + const sharedAccounts = await this._sharedAccountDB.getSharedAccountsByIdentifier( + user.getIdentifier(), + ); + const sharedAccountIds = sharedAccounts.map( + (account) => account.getDataValue('accountId') ?? 0, + ); const accountIds = accounts.map((account) => account.getDataValue('id') ?? 0); const transactions = await this._transactionDB.getTransactionFromAccounts({ ...req.data, - accountIds, + accountIds: [...sharedAccountIds, ...accountIds], }); const total = await this._transactionDB.getTotalTransactionsFromAccounts(accountIds); From 3e3f57cb2cb3d6166a5fea9eb159c65be0a22fbe Mon Sep 17 00:00:00 2001 From: Anton Stjernquist <32514829+antonstjernquist@users.noreply.github.com> Date: Mon, 3 Oct 2022 17:58:53 +0200 Subject: [PATCH 04/17] fix: perhaps smoler bundle (#110) --- web/tsconfig.json | 2 +- web/webpack.config.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/tsconfig.json b/web/tsconfig.json index bcd21407..092a6787 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es5", + "module": "esnext", "lib": [ "dom", "dom.iterable", @@ -13,7 +14,6 @@ "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "strictNullChecks": true, diff --git a/web/webpack.config.js b/web/webpack.config.js index e748f230..0d2bdc78 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -11,6 +11,7 @@ const deps = require('./package.json').dependencies; delete deps['@emotion/react']; delete deps['@emotion/styled']; delete deps['@mui/material']; +delete deps['@mui/icons-material']; delete deps['@mui/styles']; module.exports = (env, options) => ({ @@ -18,6 +19,7 @@ module.exports = (env, options) => ({ main: './src/bootstrapApp.ts', }, mode: 'development', + // devtool: 'none', output: { publicPath: 'auto', filename: '[name].js', @@ -31,7 +33,6 @@ module.exports = (env, options) => ({ 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', }, }, - devtool: 'eval-source-map', module: { rules: [ { From 8d7d98cbd27664df492364b098261188d0ea27a1 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Mon, 3 Oct 2022 22:50:01 +0200 Subject: [PATCH 05/17] fix: unload player correctly, works with esx_multicharacter --- src/server/services/account/account.service.ts | 2 +- web/src/App.tsx | 12 ++++++++---- web/src/data/transactions.ts | 12 +++++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index 815dafea..adcc72a5 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -83,7 +83,7 @@ export class AccountService { /* Override role by the shared one. */ return { - ...acc.toJSON(), + ...acc?.toJSON(), role: sharedAcc.role, }; }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 4ec4ed21..98ae3720 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,11 @@ import Devbar from '@components/DebugBar'; -import { accountsAtom } from '@data/accounts'; -import { transactionBaseAtom } from '@data/transactions'; +import { accountsAtom, rawAccountAtom } from '@data/accounts'; +import { transactionBaseAtom, transactionInitialState } from '@data/transactions'; import styled from '@emotion/styled'; import { BroadcastsWrapper } from '@hooks/useBroadcasts'; import { useExitListener } from '@hooks/useExitListener'; import { useNuiEvent } from '@hooks/useNuiEvent'; -import { NUIEvents, UserEvents } from '@typings/Events'; +import { GeneralEvents, NUIEvents, UserEvents } from '@typings/Events'; import { fetchNui } from '@utils/fetchNui'; import dayjs from 'dayjs'; import 'dayjs/locale/sv'; @@ -49,6 +49,7 @@ const Content = styled.div` const App: React.FC = () => { const config = useConfig(); + const setRawAccounts = useSetAtom(rawAccountAtom); const setAccounts = useSetAtom(accountsAtom); const setTransactions = useSetAtom(transactionBaseAtom); const [hasLoaded, setHasLoaded] = useState(process.env.NODE_ENV === 'development'); @@ -57,9 +58,12 @@ const App: React.FC = () => { useNuiEvent('PEFCL', UserEvents.Loaded, () => setHasLoaded(true)); useNuiEvent('PEFCL', UserEvents.Unloaded, () => { + setHasLoaded(false); setAccounts([]); + setRawAccounts([]); setTransactions(); - setHasLoaded(false); + fetchNui(GeneralEvents.CloseUI); + setTransactions(transactionInitialState); }); useEffect(() => { diff --git a/web/src/data/transactions.ts b/web/src/data/transactions.ts index 51d07ca0..6f22f8bd 100644 --- a/web/src/data/transactions.ts +++ b/web/src/data/transactions.ts @@ -5,7 +5,7 @@ import { mockedTransactions } from '../utils/constants'; import { fetchNui } from '../utils/fetchNui'; import { isEnvBrowser } from '../utils/misc'; -const initialState: GetTransactionsResponse = { +export const transactionInitialState: GetTransactionsResponse = { total: 0, offset: 0, limit: 10, @@ -15,17 +15,17 @@ const initialState: GetTransactionsResponse = { const getTransactions = async (input: GetTransactionsInput): Promise => { try { const res = await fetchNui(TransactionEvents.Get, input); - return res ?? initialState; + return res ?? transactionInitialState; } catch (e) { if (isEnvBrowser()) { return mockedTransactions; } console.error(e); - return initialState; + return transactionInitialState; } }; -export const rawTransactionsAtom = atom(initialState); +export const rawTransactionsAtom = atom(transactionInitialState); export const transactionBaseAtom = atom< Promise, @@ -33,7 +33,9 @@ export const transactionBaseAtom = atom< >( async (get) => { const hasTransactions = get(rawTransactionsAtom).transactions.length > 0; - return hasTransactions ? get(rawTransactionsAtom) : await getTransactions({ ...initialState }); + return hasTransactions + ? get(rawTransactionsAtom) + : await getTransactions({ ...transactionInitialState }); }, async (get, set, by?) => { const currentSettings = get(rawTransactionsAtom); From 4df63c5f455f760400a5aea196777f654b4fb749 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Mon, 12 Sep 2022 00:42:51 +0200 Subject: [PATCH 06/17] feat(cards): get, create, block, update pin for "physical" cards --- config.json | 4 + src/server/globals.server.ts | 2 +- src/server/server.ts | 7 + src/server/services/account/account.model.ts | 4 +- src/server/services/associations.ts | 11 + src/server/services/card/card.controller.ts | 63 +++++ src/server/services/card/card.db.ts | 22 ++ src/server/services/card/card.model.ts | 41 +++ src/server/services/card/card.service.ts | 147 +++++++++++ src/server/services/controllers.ts | 1 + src/server/utils/__tests__/misc.test.ts | 4 +- src/server/utils/constants.ts | 1 + src/server/utils/misc.ts | 22 +- typings/BankCard.ts | 38 +++ typings/Errors.ts | 6 + typings/Events.ts | 8 + typings/config.ts | 5 + web/src/App.tsx | 2 + .../components/{Card.tsx => AccountCard.tsx} | 30 ++- web/src/components/BankCard.tsx | 67 +++++ web/src/components/Layout.tsx | 1 + web/src/components/Modals/BaseDialog.tsx | 2 +- web/src/components/Sidebar.tsx | 4 +- web/src/components/ui/Count.tsx | 13 +- web/src/components/ui/Fields/PinField.tsx | 71 +++++ web/src/data/cards.ts | 77 ++++++ web/src/utils/constants.ts | 1 + web/src/views/Cards/CardsView.tsx | 92 +++++++ web/src/views/Cards/components/BankCards.tsx | 249 ++++++++++++++++++ .../views/Cards/components/CardActions.tsx | 207 +++++++++++++++ .../views/Accounts/MobileAccountsView.tsx | 4 +- .../views/Dashboard/MobileDashboardView.tsx | 2 +- .../dashboard/components/AccountCards.tsx | 4 +- 33 files changed, 1184 insertions(+), 28 deletions(-) create mode 100644 src/server/services/card/card.controller.ts create mode 100644 src/server/services/card/card.db.ts create mode 100644 src/server/services/card/card.model.ts create mode 100644 src/server/services/card/card.service.ts create mode 100644 typings/BankCard.ts rename web/src/components/{Card.tsx => AccountCard.tsx} (87%) create mode 100644 web/src/components/BankCard.tsx create mode 100644 web/src/components/ui/Fields/PinField.tsx create mode 100644 web/src/data/cards.ts create mode 100644 web/src/views/Cards/CardsView.tsx create mode 100644 web/src/views/Cards/components/BankCards.tsx create mode 100644 web/src/views/Cards/components/CardActions.tsx diff --git a/config.json b/config.json index fd502769..d5262acb 100644 --- a/config.json +++ b/config.json @@ -24,6 +24,10 @@ "clearingNumber": 920, "maximumNumberOfAccounts": 3 }, + "cards": { + "cost": 4500, + "maxCardsPerAccount": 2 + }, "atms": { "distance": 5.0, "props": [-870868698, -1126237515, 506770882, -1364697528], diff --git a/src/server/globals.server.ts b/src/server/globals.server.ts index 76793829..45495be9 100644 --- a/src/server/globals.server.ts +++ b/src/server/globals.server.ts @@ -7,7 +7,7 @@ export const mockedResourceName = 'pefcl'; // TODO: Move this into package const convars = { - mysql_connection_string: 'mysql://root:bruv@localhost/dev', + mysql_connection_string: 'mysql://dev:root@localhost/dev', }; const players: any = { diff --git a/src/server/server.ts b/src/server/server.ts index e49cfbb0..3cea7d70 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2,6 +2,7 @@ import './globals.server'; import { ServerPromiseResp } from '@project-error/pe-utils'; import { AccountEvents, + CardEvents, CashEvents, ExternalAccountEvents, GeneralEvents, @@ -96,6 +97,12 @@ if (isMocking) { app.post(...createEndpoint(ExternalAccountEvents.Get)); app.post(...createEndpoint(CashEvents.GetMyCash)); + // Cards + app.post(...createEndpoint(CardEvents.Get)); + app.post(...createEndpoint(CardEvents.OrderPersonal)); + app.post(...createEndpoint(CardEvents.UpdatePin)); + app.post(...createEndpoint(CardEvents.Block)); + app.listen(port, async () => { mainLogger.child({ module: 'server' }).debug(`[MOCKSERVER]: listening on port: ${port}`); diff --git a/src/server/services/account/account.model.ts b/src/server/services/account/account.model.ts index 271e9843..10d03da9 100644 --- a/src/server/services/account/account.model.ts +++ b/src/server/services/account/account.model.ts @@ -3,7 +3,7 @@ import { DataTypes, Model, Optional } from 'sequelize'; import { config } from '@utils/server-config'; import { Account, AccountRole, AccountType } from '@typings/Account'; import { sequelize } from '@utils/pool'; -import { generateAccountNumber } from '@utils/misc'; +import { generateClearingNumber } from '@utils/misc'; import { timestamps } from '../timestamps.model'; import { AccountEvents } from '@server/../../typings/Events'; @@ -22,7 +22,7 @@ AccountModel.init( number: { type: DataTypes.STRING, unique: true, - defaultValue: generateAccountNumber, + defaultValue: generateClearingNumber, }, accountName: { type: DataTypes.STRING, diff --git a/src/server/services/associations.ts b/src/server/services/associations.ts index faeb29b2..20285b20 100644 --- a/src/server/services/associations.ts +++ b/src/server/services/associations.ts @@ -4,6 +4,7 @@ import { AccountModel } from './account/account.model'; import { TransactionModel } from './transaction/transaction.model'; import './invoice/invoice.model'; import { SharedAccountModel } from './accountShared/sharedAccount.model'; +import { CardModel } from './card/card.model'; /* This is so annoying. Next time choose something with TS support. */ declare module './accountShared/sharedAccount.model' { @@ -19,6 +20,12 @@ declare module './transaction/transaction.model' { } } +declare module './card/card.model' { + interface CardModel { + setAccount(id?: number): Promise; + } +} + TransactionModel.belongsTo(AccountModel, { as: 'toAccount', }); @@ -31,6 +38,10 @@ SharedAccountModel.belongsTo(AccountModel, { as: 'account', }); +CardModel.belongsTo(AccountModel, { + as: 'account', +}); + if (config?.database?.shouldSync) { sequelize.sync(); } diff --git a/src/server/services/card/card.controller.ts b/src/server/services/card/card.controller.ts new file mode 100644 index 00000000..8f2945c0 --- /dev/null +++ b/src/server/services/card/card.controller.ts @@ -0,0 +1,63 @@ +import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; +import { + BlockCardInput, + Card, + CreateCardInput, + GetCardInput, + UpdateCardPinInput, +} from '@server/../../typings/BankCard'; +import { CardEvents } from '@typings/Events'; +import { Request, Response } from '@typings/http'; +import { Controller } from '../../decorators/Controller'; +import { EventListener } from '../../decorators/Event'; +import { CardService } from './card.service'; + +@Controller('Card') +@EventListener() +@PromiseEventListener() +export class CardController { + cardService: CardService; + constructor(cardService: CardService) { + this.cardService = cardService; + } + + @NetPromise(CardEvents.OrderPersonal) + async orderPersonalAccount(req: Request, res: Response) { + try { + const result = await this.cardService.orderPersonalCard(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.Block) + async blockCard(req: Request, res: Response) { + try { + const isUpdated = await this.cardService.blockCard(req); + res({ status: 'ok', data: isUpdated }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.UpdatePin) + async updatePin(req: Request, res: Response) { + try { + const isUpdated = await this.cardService.updateCardPin(req); + res({ status: 'ok', data: isUpdated }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + + @NetPromise(CardEvents.Get) + async getCards(req: Request, res: Response) { + try { + const result = await this.cardService.getCards(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } +} diff --git a/src/server/services/card/card.db.ts b/src/server/services/card/card.db.ts new file mode 100644 index 00000000..f3f9e872 --- /dev/null +++ b/src/server/services/card/card.db.ts @@ -0,0 +1,22 @@ +import { Transaction } from 'sequelize/types'; +import { singleton } from 'tsyringe'; +import { CardModel, CardModelCreate } from './card.model'; + +@singleton() +export class CardDB { + async getAll(): Promise { + return await CardModel.findAll(); + } + + async getById(cardId: number): Promise { + return await CardModel.findOne({ where: { id: cardId } }); + } + + async getByAccountId(accountId: number): Promise { + return await CardModel.findAll({ where: { accountId: accountId } }); + } + + async create(data: CardModelCreate, transaction: Transaction): Promise { + return await CardModel.create(data, { transaction }); + } +} diff --git a/src/server/services/card/card.model.ts b/src/server/services/card/card.model.ts new file mode 100644 index 00000000..de6bf1ed --- /dev/null +++ b/src/server/services/card/card.model.ts @@ -0,0 +1,41 @@ +import { Card } from '@server/../../typings/BankCard'; +import { generateCardNumber } from '@server/utils/misc'; +import { DATABASE_PREFIX } from '@utils/constants'; +import { DataTypes, Model, Optional } from 'sequelize'; +import { singleton } from 'tsyringe'; +import { sequelize } from '../../utils/pool'; +import { timestamps } from '../timestamps.model'; + +export type CardModelCreate = Optional< + Card, + 'id' | 'number' | 'pin' | 'isBlocked' | 'createdAt' | 'updatedAt' +>; + +@singleton() +export class CardModel extends Model {} +CardModel.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + holder: { + type: DataTypes.STRING, + }, + isBlocked: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + number: { + type: DataTypes.STRING, + defaultValue: generateCardNumber, + }, + pin: { + type: DataTypes.INTEGER, + defaultValue: 1234, + }, + ...timestamps, + }, + { sequelize: sequelize, tableName: DATABASE_PREFIX + 'cards' }, +); diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts new file mode 100644 index 00000000..fc6b9cd6 --- /dev/null +++ b/src/server/services/card/card.service.ts @@ -0,0 +1,147 @@ +import { singleton } from 'tsyringe'; +import { config } from '@utils/server-config'; +import { mainLogger } from '../../sv_logger'; +import { UserService } from '../user/user.service'; +import { Request } from '@typings/http'; +import { CardDB } from './card.db'; +import { sequelize } from '@server/utils/pool'; +import { AccountService } from '../account/account.service'; +import { CardErrors, GenericErrors } from '@server/../../typings/Errors'; +import i18next from '@utils/i18n'; +import { + BlockCardInput, + Card, + CreateCardInput, + UpdateCardPinInput, +} from '@server/../../typings/BankCard'; +import { AccountDB } from '../account/account.db'; +import { PIN_CODE_LENGTH } from '@server/utils/constants'; + +const logger = mainLogger.child({ module: 'card' }); + +@singleton() +export class CardService { + cardDB: CardDB; + accountDB: AccountDB; + userService: UserService; + accountService: AccountService; + + constructor( + CardDB: CardDB, + userService: UserService, + accountService: AccountService, + accountDB: AccountDB, + ) { + this.cardDB = CardDB; + this.accountDB = accountDB; + this.userService = userService; + this.accountService = accountService; + } + + async getCards(req: Request<{ accountId: number }>) { + const cards = await this.cardDB.getByAccountId(req.data.accountId); + return cards.map((card) => card.toJSON()); + } + + async blockCard(req: Request) { + const { cardId, pin } = req.data; + const card = await this.cardDB.getById(cardId); + + if (!card) { + throw new Error(GenericErrors.NotFound); + } + + if (pin !== card.getDataValue('pin')) { + throw new Error(CardErrors.InvalidPin); + } + + try { + await card.update({ isBlocked: true }); + return true; + } catch (error) { + return false; + } + } + + async updateCardPin(req: Request): Promise { + logger.silly('Ordering new card for source:' + req.source); + + const { cardId, newPin, oldPin } = req.data; + const card = await this.cardDB.getById(cardId); + + if (!card) { + throw new Error(GenericErrors.NotFound); + } + + if (card.getDataValue('pin') !== oldPin) { + throw new Error(CardErrors.InvalidPin); + } + + const t = await sequelize.transaction(); + try { + await card.update({ pin: newPin }, { transaction: t }); + t.commit(); + return true; + } catch (error) { + logger.error(error); + t.rollback(); + return false; + } + } + + async orderPersonalCard(req: Request): Promise { + logger.debug('Ordering new card for source:' + req.source); + const { accountId, paymentAccountId, pin } = req.data; + + const user = this.userService.getUser(req.source); + const newCardCost = config.cards?.cost; + + if (!newCardCost) { + logger.error('Missing "cards.cost" in config.json'); + throw new Error(CardErrors.MissingConfigCost); + } + + if (pin.toString().length !== PIN_CODE_LENGTH) { + logger.error('Pin is wrong length, should be: ' + PIN_CODE_LENGTH); + throw new Error(GenericErrors.BadInput); + } + + const t = await sequelize.transaction(); + try { + const account = await this.accountService.getAuthorizedAccount(req.source, accountId); + const paymentAccount = await this.accountService.getAuthorizedAccount( + req.source, + paymentAccountId, + ); + + if (!account || !paymentAccount) { + throw new Error(GenericErrors.NotFound); + } + + const card = await this.cardDB.create( + { + pin: pin, + holder: user.name, + accountId: account.getDataValue('id'), + }, + t, + ); + + this.accountService.removeMoneyByAccountNumber({ + ...req, + data: { + amount: newCardCost, + message: i18next.t('Ordered new card'), + accountNumber: paymentAccount.getDataValue('number'), + }, + }); + + t.commit(); + return card.toJSON(); + } catch (err) { + logger.error(err); + t.rollback(); + throw new Error(i18next.t('Failed to create new account')); + } + } +} diff --git a/src/server/services/controllers.ts b/src/server/services/controllers.ts index 70a2c640..c5484128 100644 --- a/src/server/services/controllers.ts +++ b/src/server/services/controllers.ts @@ -5,3 +5,4 @@ import './invoice/invoice.controller'; import './account/account.controller'; import './transaction/transaction.controller'; import './broadcast/broadcast.controller'; +import './card/card.controller'; diff --git a/src/server/utils/__tests__/misc.test.ts b/src/server/utils/__tests__/misc.test.ts index 7df4f059..04c96513 100644 --- a/src/server/utils/__tests__/misc.test.ts +++ b/src/server/utils/__tests__/misc.test.ts @@ -1,5 +1,5 @@ import { DEFAULT_CLEARING_NUMBER } from '@utils/constants'; -import { generateAccountNumber, getClearingNumber } from '@utils/misc'; +import { generateClearingNumber, getClearingNumber } from '@utils/misc'; import { createMockedConfig } from '@utils/test'; import { regexExternalNumber } from '@shared/utils/regexes'; @@ -49,7 +49,7 @@ describe('Helper: getClearingNumber', () => { describe('Helper: generateAccountNumber', () => { test('should pass regex test', () => { for (let i = 0; i < 100; i++) { - const accountNumber = generateAccountNumber(); + const accountNumber = generateClearingNumber(); expect(regexExternalNumber.test(accountNumber)).toBe(true); } }); diff --git a/src/server/utils/constants.ts b/src/server/utils/constants.ts index 60e48d42..be23da26 100644 --- a/src/server/utils/constants.ts +++ b/src/server/utils/constants.ts @@ -1,6 +1,7 @@ export const resourceName = 'pefcl'; export const DATABASE_PREFIX = 'pefcl_'; export const DEFAULT_CLEARING_NUMBER = 920; +export const PIN_CODE_LENGTH = 5; export const MS_ONE_DAY = 1000 * 60 * 60 * 24; export const MS_ONE_WEEK = MS_ONE_DAY * 7; diff --git a/src/server/utils/misc.ts b/src/server/utils/misc.ts index ce55c696..c39c5f7c 100644 --- a/src/server/utils/misc.ts +++ b/src/server/utils/misc.ts @@ -45,7 +45,7 @@ export const getClearingNumber = (initialConfig = config): string => { return confValue; }; -export const generateAccountNumber = (clearingNumber = getClearingNumber()): string => { +export const generateClearingNumber = (clearingNumber = getClearingNumber()): string => { const initialNumber = clearingNumber; let uuid = `${initialNumber},`; @@ -67,6 +67,26 @@ export const generateAccountNumber = (clearingNumber = getClearingNumber()): str return uuid; }; +export const generateCardNumber = (): string => { + let uuid = `5160 `; + for (let i = 0; i < 12; i++) { + switch (i) { + case 8: + uuid += ' '; + uuid += ((Math.random() * 4) | 0).toString(); + break; + case 4: + uuid += ' '; + uuid += ((Math.random() * 4) | 0).toString(); + break; + default: + uuid += ((Math.random() * 9) | 0).toString(10); + } + } + + return uuid; +}; + // Credits to d0p3t // https://github.com/d0p3t/fivem-js/blob/master/src/utils/UUIDV4.ts export const uuidv4 = (): string => { diff --git a/typings/BankCard.ts b/typings/BankCard.ts new file mode 100644 index 00000000..db6dcea1 --- /dev/null +++ b/typings/BankCard.ts @@ -0,0 +1,38 @@ +import { Account } from './Account'; + +export interface Card { + // Dynamic + id: number; + account?: Account; + accountId?: number; + + pin: number; + isBlocked: boolean; + + // Static + holder: string; + number: string; + + // Timestamps + updatedAt?: string | number | Date; + createdAt?: string | number | Date; +} + +export interface GetCardInput { + accountId: number; +} +export interface CreateCardInput { + pin: number; + accountId: number; + paymentAccountId: number; +} +export interface BlockCardInput { + cardId: number; + pin: number; +} + +export interface UpdateCardPinInput { + cardId: number; + newPin: number; + oldPin: number; +} diff --git a/typings/Errors.ts b/typings/Errors.ts index f9a9314c..2657854b 100644 --- a/typings/Errors.ts +++ b/typings/Errors.ts @@ -16,6 +16,12 @@ export enum AccountErrors { SameAccount = 'SameAccount', } +export enum CardErrors { + MissingConfigCost = 'MissingConfigCost', + FailedToCreate = 'FailedToCreate', + InvalidPin = 'InvalidPin', +} + export enum UserErrors { NotFound = 'UserNotFound', } diff --git a/typings/Events.ts b/typings/Events.ts index 4893f011..77342879 100644 --- a/typings/Events.ts +++ b/typings/Events.ts @@ -77,3 +77,11 @@ export enum CashEvents { export enum BalanceEvents { UpdateCashBalance = 'pefcl:updateCashBalance', } + +export enum CardEvents { + Get = 'pefcl:getCards', + OrderShared = 'pefcl:orderSharedCard', + OrderPersonal = 'pefcl:orderPersonalCard', + Block = 'pefcl:blockCard', + UpdatePin = 'pefcl:updatePin', +} diff --git a/typings/config.ts b/typings/config.ts index e6f8300e..4745107c 100644 --- a/typings/config.ts +++ b/typings/config.ts @@ -48,6 +48,11 @@ export interface ResourceConfig { clearingNumber: string | number; maximumNumberOfAccounts: number; }; + cards: { + cost: number; + pinLength: number; + maxCardsPerAccount: number; + }; cash: { startAmount: number; }; diff --git a/web/src/App.tsx b/web/src/App.tsx index 98ae3720..155a821c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -25,6 +25,7 @@ import Accounts from './views/accounts/Accounts'; import Dashboard from './views/dashboard/Dashboard'; import Transactions from './views/transactions/Transactions'; import Transfer from './views/transfer/Transfer'; +import CardsView from './views/Cards/CardsView'; dayjs.extend(updateLocale); @@ -106,6 +107,7 @@ const App: React.FC = () => { + )} diff --git a/web/src/components/Card.tsx b/web/src/components/AccountCard.tsx similarity index 87% rename from web/src/components/Card.tsx rename to web/src/components/AccountCard.tsx index d91efafc..471f9e3c 100644 --- a/web/src/components/Card.tsx +++ b/web/src/components/AccountCard.tsx @@ -40,7 +40,7 @@ const Container = styled.div<{ accountType: AccountType; selected: boolean }>` ${({ selected }) => selected && ` - border: 2px solid ${theme.palette.background.light8}; + border: 2px solid ${theme.palette.primary.light}; `}; `; @@ -77,9 +77,15 @@ const DefaultText = styled(Heading6)` type AccountCardProps = { account: Account; selected?: boolean; + withCopy?: boolean; }; -export const AccountCard = ({ account, selected = false, ...props }: AccountCardProps) => { +export const AccountCard = ({ + account, + selected = false, + withCopy = false, + ...props +}: AccountCardProps) => { const { type, id, balance, isDefault, accountName, number } = account; const { t } = useTranslation(); const config = useConfig(); @@ -96,14 +102,16 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard {number} - copy(number)} - size="small" - color="inherit" - style={{ opacity: '0.45', marginTop: 0, marginLeft: '0.25rem' }} - > - - + {withCopy && ( + copy(number)} + size="small" + color="inherit" + style={{ opacity: '0.45', marginTop: 0, marginLeft: '0.25rem' }} + > + + + )} @@ -111,8 +119,6 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard {t('Account name')} {accountName} - - ); diff --git a/web/src/components/BankCard.tsx b/web/src/components/BankCard.tsx new file mode 100644 index 00000000..a847750a --- /dev/null +++ b/web/src/components/BankCard.tsx @@ -0,0 +1,67 @@ +import { Stack } from '@mui/material'; +import { Card } from '@typings/BankCard'; +import theme from '@utils/theme'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { MasterCardIcon } from 'src/icons/MasterCardIcon'; +import styled from 'styled-components'; +import { BodyText } from './ui/Typography/BodyText'; +import { Heading4, Heading6 } from './ui/Typography/Headings'; + +const Container = styled.div<{ selected: boolean; blocked: boolean }>` + user-select: none; + width: 100%; + padding: 1rem; + background: ${({ blocked }) => + blocked + ? 'linear-gradient(90deg, #bcbcbc 0%, #b0b0b0 100%)' + : 'linear-gradient(90deg, #fc5f02 0%, #f43200 100%)'}; + + min-height: 7rem; + width: auto; + border-radius: ${theme.spacing(1)}; + + cursor: pointer; + transition: 250ms; + box-shadow: ${theme.shadows[4]}; + + :hover { + box-shadow: ${theme.shadows[6]}; + } + + transition: 200ms ease-in-out; + border: 2px solid transparent; + + ${({ selected }) => selected && `border: 2px solid ${theme.palette.text.primary}`} +`; + +const StyledIcon = styled(MasterCardIcon)` + color: rgba(255, 255, 255, 0.54); + align-self: flex-end; +`; + +interface BankCardProps { + card: Card; + selected?: boolean; +} +const BankCard = ({ card, selected = false }: BankCardProps) => { + const { t } = useTranslation(); + + return ( + + + {card.number} + + + {t('Card holder')} + {card.holder} + + + + + + + ); +}; + +export default BankCard; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 624fa809..c4db56a0 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -14,6 +14,7 @@ const Container = styled.div` `; const Content = styled(motion.div)` + position: relative; padding: 2rem; flex: 1; height: 100%; diff --git a/web/src/components/Modals/BaseDialog.tsx b/web/src/components/Modals/BaseDialog.tsx index c48cf590..ada35818 100644 --- a/web/src/components/Modals/BaseDialog.tsx +++ b/web/src/components/Modals/BaseDialog.tsx @@ -1,5 +1,5 @@ import { useGlobalSettings } from '@hooks/useGlobalSettings'; -import { Dialog, DialogProps } from '@mui/material'; +import { Backdrop, Dialog, DialogProps } from '@mui/material'; import React, { ReactNode } from 'react'; interface BaseDialogProps extends DialogProps { diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 3f4c5c94..6720a027 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react'; import { + AccountBalanceRounded, Add, CreditCardRounded, DashboardRounded, @@ -100,7 +101,7 @@ const Sidebar = () => { return ( } label={t('Dashboard')} /> - } label={t('Accounts')} /> + } label={t('Accounts')} /> } label={t('Transfer')} /> } label={t('Transactions')} /> { /> } label={t('Deposit')} /> } label={t('Withdraw')} /> + } label={t('Cards')} /> ); }; diff --git a/web/src/components/ui/Count.tsx b/web/src/components/ui/Count.tsx index 02f4ed8d..f6d85106 100644 --- a/web/src/components/ui/Count.tsx +++ b/web/src/components/ui/Count.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import theme from '@utils/theme'; import React from 'react'; -const Total = styled.div` +const Total = styled.div<{ focus: boolean }>` display: flex; justify-content: center; align-items: center; @@ -14,15 +14,22 @@ const Total = styled.div` border-radius: ${theme.spacing(1)}; font-weight: ${theme.typography.fontWeightBold}; background-color: ${theme.palette.background.light4}; + + ${({ focus }) => + focus && + ` + background-color: ${theme.palette.background.light8}; + `} `; interface CountProps extends React.HTMLAttributes { amount: string | number; + focus?: boolean; } -const Count = ({ amount, ...props }: CountProps) => { +const Count = ({ amount, focus = false, ...props }: CountProps) => { return (
- {amount} + {amount}
); }; diff --git a/web/src/components/ui/Fields/PinField.tsx b/web/src/components/ui/Fields/PinField.tsx new file mode 100644 index 00000000..1fa95e2d --- /dev/null +++ b/web/src/components/ui/Fields/PinField.tsx @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; +import { InputBaseProps, Stack, Typography } from '@mui/material'; +import { PIN_CODE_LENGTH } from '@utils/constants'; +import React, { ChangeEvent, useState } from 'react'; +import Count from '../Count'; + +const Container = styled.div` + position: relative; + display: grid; + grid-template-columns: ${`repeat(${PIN_CODE_LENGTH}, 1fr)`}; + grid-column-gap: 0.5rem; + width: ${`calc(${PIN_CODE_LENGTH} * 3rem)`}; +`; + +const InputField = styled.input` + position: absolute; + opacity: 0; + width: 100%; + height: 100%; +`; + +interface PinFieldProps { + label?: string; + value: string; + onChange: InputBaseProps['onChange']; +} + +const PinField = ({ onChange, value, label }: PinFieldProps) => { + const [hasFocus, setHasFocus] = useState(false); + + const handleChange = (event: ChangeEvent) => { + const newValue = event.target.value; + const newLength = newValue.length; + + if (newValue && isNaN(parseInt(newValue, 10))) { + return; + } + + if (newLength > PIN_CODE_LENGTH && value.length < newLength) { + return; + } + + onChange?.(event); + }; + + const codeLen = new Array(PIN_CODE_LENGTH).fill(''); + + return ( + + {label && ( + + {label} + + )} + + setHasFocus(false)} + onFocus={() => setHasFocus(true)} + /> + + {codeLen.map((_val, index) => ( + + ))} + + + ); +}; + +export default PinField; diff --git a/web/src/data/cards.ts b/web/src/data/cards.ts new file mode 100644 index 00000000..3497e026 --- /dev/null +++ b/web/src/data/cards.ts @@ -0,0 +1,77 @@ +import { Card, GetCardInput } from '@typings/BankCard'; +import { CardEvents } from '@typings/Events'; +import { mockedAccounts } from '@utils/constants'; +import { fetchNui } from '@utils/fetchNui'; +import { isEnvBrowser } from '@utils/misc'; +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; + +const mockedCards: Card[] = [ + { + id: 1, + account: mockedAccounts[0], + isBlocked: false, + number: '4242 4220 1234 9000', + holder: 'Charles Carlsberg', + pin: 1234, + }, + { + id: 2, + account: mockedAccounts[0], + isBlocked: false, + number: '4242 4220 1234 9002', + holder: 'Charles Carlsberg', + pin: 1234, + }, + { + id: 3, + account: mockedAccounts[0], + isBlocked: false, + number: '4242 4220 1234 9003', + holder: 'Charles Carlsberg', + pin: 1234, + }, +]; + +const getCards = async (accountId: number): Promise => { + try { + const res = await fetchNui(CardEvents.Get, { accountId }); + return res ?? []; + } catch (e) { + if (isEnvBrowser()) { + return mockedCards; + } + + console.error(e); + return []; + } +}; + +export const selectedAccountIdAtom = atom(0); + +export const rawCardAtom = atomWithStorage>('rawCards', {}); +export const cardsAtom = atom( + async (get) => { + const selectedCardId = get(selectedAccountIdAtom); + const state = get(rawCardAtom); + + return state[selectedCardId] ?? []; + }, + async (get, set, by: Card | number) => { + const selectedCardId = get(selectedAccountIdAtom); + const state = get(rawCardAtom); + + if (typeof by === 'number') { + const cards = await getCards(by); + return set(rawCardAtom, { ...state, [selectedCardId]: cards }); + } + + if (!by) { + const cards = await getCards(selectedCardId); + return set(rawCardAtom, { ...state, [selectedCardId]: cards }); + } + + const cards = state[selectedCardId]; + return set(rawCardAtom, { ...state, [selectedCardId]: [...cards, by] }); + }, +); diff --git a/web/src/utils/constants.ts b/web/src/utils/constants.ts index 5d7e4e34..e0ee7f1b 100644 --- a/web/src/utils/constants.ts +++ b/web/src/utils/constants.ts @@ -7,6 +7,7 @@ export const resourceDefaultName = 'pefcl'; const now = dayjs(); +export const PIN_CODE_LENGTH = 5; export const DEFAULT_PAGINATION_LIMIT = 5; export const defaultWithdrawOptions = [500, 1000, 1500, 3000, 5000, 7500]; diff --git a/web/src/views/Cards/CardsView.tsx b/web/src/views/Cards/CardsView.tsx new file mode 100644 index 00000000..fad42b19 --- /dev/null +++ b/web/src/views/Cards/CardsView.tsx @@ -0,0 +1,92 @@ +import { AccountCard } from '@components/AccountCard'; +import Layout from '@components/Layout'; +import { PreHeading } from '@components/ui/Typography/BodyText'; +import { Heading1 } from '@components/ui/Typography/Headings'; +import { accountsAtom } from '@data/accounts'; +import { Backdrop, Stack } from '@mui/material'; +import { useAtom } from 'jotai'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { AnimatePresence, motion } from 'framer-motion'; +import theme from '@utils/theme'; +import BankCards from './components/BankCards'; +import { selectedAccountIdAtom } from '@data/cards'; + +const Container = styled.div` + overflow: auto; + height: 100%; +`; + +const CardContainer = styled.div` + display: flex; + flex-wrap: wrap; + margin-top: 1rem; + margin-left: -0.5rem; + + & > div { + width: calc(33% - 0.5rem); + margin-left: 0.5rem; + margin-top: 0.5rem; + } +`; + +const Modal = styled(motion.div)` + z-index: 2; + padding: 2rem 3rem; + position: absolute; + width: calc(100% - 5rem); + height: 100%; + top: 0; + left: 5rem; + background-color: ${theme.palette.background.paper}; +`; + +const CardsView = () => { + const [selectedCardId, setSelectedCardId] = useState(0); + const [selectedAccountId, setSelectedAccountId] = useAtom(selectedAccountIdAtom); + const [accounts] = useAtom(accountsAtom); + const { t } = useTranslation(); + + return ( + + + + {t('Accounts')} + {t('Handle cards for your accounts')} + + + + {accounts.map((account) => ( +
setSelectedAccountId(account.id)}> + +
+ ))} +
+ + { + setSelectedAccountId(0); + setSelectedCardId(0); + }} + sx={{ position: 'absolute' }} + /> + + + {Boolean(selectedAccountId) && ( + + + + )} + +
+
+ ); +}; + +export default CardsView; diff --git a/web/src/views/Cards/components/BankCards.tsx b/web/src/views/Cards/components/BankCards.tsx new file mode 100644 index 00000000..6a3f364f --- /dev/null +++ b/web/src/views/Cards/components/BankCards.tsx @@ -0,0 +1,249 @@ +import { PreHeading } from '@components/ui/Typography/BodyText'; +import { Heading1 } from '@components/ui/Typography/Headings'; +import React, { useEffect, useState } from 'react'; +import BankCard from '@components/BankCard'; +import { AddRounded, ErrorRounded, InfoRounded } from '@mui/icons-material'; +import { Alert, Backdrop, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'; +import { Card, CreateCardInput } from '@typings/BankCard'; +import theme from '@utils/theme'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import CardActions from './CardActions'; +import { useConfig } from '@hooks/useConfig'; +import BaseDialog from '@components/Modals/BaseDialog'; +import { fetchNui } from '@utils/fetchNui'; +import { CardEvents } from '@typings/Events'; +import { useAtom } from 'jotai'; +import { cardsAtom } from '@data/cards'; +import { AnimatePresence, motion } from 'framer-motion'; +import Button from '@components/ui/Button'; +import AccountSelect from '@components/AccountSelect'; +import Summary from '@components/Summary'; +import { accountsAtom } from '@data/accounts'; +import PinField from '@components/ui/Fields/PinField'; + +const CreateCard = styled.div` + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + border-radius: ${theme.spacing(1)}; + border: 1px dashed ${theme.palette.grey[500]}; + font-size: 1.75rem; + transition: 300ms; + + min-height: 7rem; + width: auto; + + :hover { + color: ${theme.palette.primary.main}; + border: 1px dashed ${theme.palette.primary.main}; + } + + svg { + font-size: 2.5rem; + } +`; + +const CardContainer = styled.div` + display: flex; + flex-wrap: wrap; + height: 100%; + overflow: auto; + margin-top: 1rem; + margin-left: -0.5rem; + + & > div { + width: calc(33% - 0.5rem); + margin-left: 0.5rem; + margin-top: 0.5rem; + } +`; + +const Modal = styled(motion.div)` + z-index: 2; + padding: 2rem 3rem; + position: absolute; + width: calc(100% - 5rem); + height: 100%; + top: 0; + left: 5rem; + background-color: ${theme.palette.background.paper}; +`; + +interface BankCardsProps { + accountId: number; + selectedCardId: number; + onSelectCardId(id: number): void; +} + +const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps) => { + const { t } = useTranslation(); + const [accounts] = useAtom(accountsAtom); + const defaultAccount = accounts.find((account) => Boolean(account.isDefault)); + const initialAccountId = defaultAccount?.id ?? -1; + const [cards, updateCards] = useAtom(cardsAtom); + const [error, setError] = useState(''); + const [pin, setPin] = useState(''); + const [confirmPin, setConfirmPin] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isOrderingCard, setIsOrderingCard] = useState(false); + const [selectedAccountId, setSelectedAccountId] = useState(initialAccountId); + const { + cards: { cost, maxCardsPerAccount }, + } = useConfig(); + + useEffect(() => { + updateCards(accountId); + }, [accountId, updateCards]); + + const handleClose = () => { + setError(''); + setIsLoading(false); + setIsOrderingCard(false); + setPin(''); + setConfirmPin(''); + }; + + const handleOrderCard = async () => { + if (confirmPin !== pin) { + setError(t('Pins do not match')); + return; + } + + setError(''); + setIsLoading(true); + try { + const newCard = await fetchNui(CardEvents.OrderPersonal, { + accountId, + paymentAccountId: selectedAccountId, + pin: parseInt(pin, 10), + }); + + if (!newCard) { + return; + } + + updateCards(newCard); + handleClose(); + } catch (error: unknown) { + if (error instanceof Error) { + setError(error.message); + } + } + + setIsLoading(false); + }; + + const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId); + + return ( + + + + {t('Cards')} + {t('Select a card to handle, or order a new one.')} + + + + {cards.map((card) => ( +
{ + !card.isBlocked && onSelectCardId(card.id); + }} + > + +
+ ))} + + {cards.length < maxCardsPerAccount && ( + setIsOrderingCard(true)}> + + + + + )} +
+
+ + { + onSelectCardId(0); + }} + sx={{ position: 'absolute', left: '-2rem' }} + /> + + + {Boolean(selectedCardId) && ( + + { + updateCards(accountId); + onSelectCardId(0); + }} + /> + + )} + + + + {t('Order a new card')} + + + + setPin(event.target.value)} + /> + + setConfirmPin(event.target.value)} + /> + + + + + + + + + + {error && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + ); +}; + +export default BankCards; diff --git a/web/src/views/Cards/components/CardActions.tsx b/web/src/views/Cards/components/CardActions.tsx new file mode 100644 index 00000000..798af02d --- /dev/null +++ b/web/src/views/Cards/components/CardActions.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import Button from '@components/ui/Button'; +import { + Alert, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; +import { Heading1 } from '@components/ui/Typography/Headings'; +import { PreHeading } from '@components/ui/Typography/BodyText'; +import { useTranslation } from 'react-i18next'; +import BaseDialog from '@components/Modals/BaseDialog'; +import { fetchNui } from '@utils/fetchNui'; +import { CardEvents } from '@typings/Events'; +import { CheckRounded, ErrorRounded, InfoRounded } from '@mui/icons-material'; +import { BlockCardInput, UpdateCardPinInput } from '@typings/BankCard'; +import PinField from '@components/ui/Fields/PinField'; + +interface CardActionsProps { + cardId: number; + onBlock?(): void; +} + +const CardActions = ({ cardId, onBlock }: CardActionsProps) => { + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isUpdatingPin, setIsUpdatingPin] = useState(false); + const [isBlockingCard, setIsBlockingCard] = useState(false); + const [pin, setPin] = useState(''); + const [oldPin, setOldPin] = useState(''); + const [newPin, setNewPin] = useState(''); + const [confirmNewPin, setConfirmNewPin] = useState(''); + const { t } = useTranslation(); + + const handleClose = () => { + setIsLoading(false); + setIsUpdatingPin(false); + setIsBlockingCard(false); + setError(''); + setNewPin(''); + setOldPin(''); + setConfirmNewPin(''); + + setTimeout(() => { + setSuccess(''); + }, 2000); + }; + + const handleBlockCard = async () => { + try { + setSuccess(''); + setError(''); + setIsLoading(true); + + await fetchNui(CardEvents.Block, { + cardId, + pin: parseInt(pin, 10), + }); + setSuccess(t('Successfully blocked the card.')); + handleClose(); + onBlock?.(); + } catch (err: unknown | Error) { + if (err instanceof Error) { + setError(err.message); + } + } + + setIsLoading(false); + }; + + const handleUpdatePin = async () => { + try { + setError(''); + setSuccess(''); + + if (confirmNewPin !== newPin) { + setError(t('Pins do not match')); + return; + } + + setIsLoading(true); + const data = { cardId, newPin: parseInt(newPin, 10), oldPin: parseInt(oldPin, 10) }; + await fetchNui(CardEvents.UpdatePin, data); + + setSuccess(t('Successfully updated pin.')); + handleClose(); + } catch (err: unknown | Error) { + if (err instanceof Error) { + setError(err.message); + } + } + + setIsLoading(false); + }; + + return ( + <> + + + + {t('Actions')} + {t('Block, update pin and more.')} + + + + + + + + {success && ( + } color="success"> + {success} + + )} + + + + + {t('Update pin')} + + + + setOldPin(event.target.value)} + /> + setNewPin(event.target.value)} + /> + setConfirmNewPin(event.target.value)} + /> + + + {(Boolean(error) || isLoading) && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + + + {t('Blocking card')} + + + + + {t('Are you sure you want to block this card? This action cannot be undone.')} + + {t('Enter card pin to block the card.')} + setPin(event.target.value)} + /> + + + {error && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + + + ); +}; + +export default CardActions; diff --git a/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx b/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx index 5d59bc9c..5d2b3951 100644 --- a/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx +++ b/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx @@ -1,11 +1,11 @@ -import { AccountCard } from '@components/Card'; +import React from 'react'; +import { AccountCard } from '@components/AccountCard'; import TotalBalance from '@components/TotalBalance'; import { Heading5 } from '@components/ui/Typography/Headings'; import { accountsAtom } from '@data/accounts'; import { Stack } from '@mui/material'; import { Box } from '@mui/system'; import { useAtom } from 'jotai'; -import React from 'react'; import { useTranslation } from 'react-i18next'; const MobileAccountsView = () => { diff --git a/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx b/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx index 6d2fbaea..029b6c3c 100644 --- a/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx +++ b/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx @@ -1,4 +1,4 @@ -import { AccountCard } from '@components/Card'; +import { AccountCard } from '@components/AccountCard'; import InvoiceItem from '@components/InvoiceItem'; import TotalBalance from '@components/TotalBalance'; import TransactionItem from '@components/TransactionItem'; diff --git a/web/src/views/dashboard/components/AccountCards.tsx b/web/src/views/dashboard/components/AccountCards.tsx index 53cf9bb9..31e73825 100644 --- a/web/src/views/dashboard/components/AccountCards.tsx +++ b/web/src/views/dashboard/components/AccountCards.tsx @@ -1,4 +1,4 @@ -import { AccountCard, LoadingAccountCard } from '@components/Card'; +import { AccountCard, LoadingAccountCard } from '@components/AccountCard'; import CreateAccountModal from '@components/Modals/CreateAccount'; import { orderedAccountsAtom } from '@data/accounts'; import styled from '@emotion/styled'; @@ -89,7 +89,7 @@ const AccountCards = ({ onSelectAccount, selectedAccountId }: AccountCardsProps) {orderedAccounts.map((account) => ( onSelectAccount?.(account.id)}> - + ))} From 7d9383cb7e50d97856c22554f17b901dd9fee8d4 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Mon, 12 Sep 2022 00:46:46 +0200 Subject: [PATCH 07/17] fix: updated tsconfig & paths, added shared/constants --- shared/constants.ts | 1 + src/server/services/card/card.service.ts | 2 +- src/server/utils/constants.ts | 1 - web/src/components/ui/Fields/PinField.tsx | 2 +- web/src/utils/constants.ts | 5 ++--- web/tsconfig.json | 3 ++- 6 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 shared/constants.ts diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 00000000..dab9bb47 --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1 @@ +export const PIN_CODE_LENGTH = 4; diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts index fc6b9cd6..4ddbf86d 100644 --- a/src/server/services/card/card.service.ts +++ b/src/server/services/card/card.service.ts @@ -15,7 +15,7 @@ import { UpdateCardPinInput, } from '@server/../../typings/BankCard'; import { AccountDB } from '../account/account.db'; -import { PIN_CODE_LENGTH } from '@server/utils/constants'; +import { PIN_CODE_LENGTH } from '@shared/constants'; const logger = mainLogger.child({ module: 'card' }); diff --git a/src/server/utils/constants.ts b/src/server/utils/constants.ts index be23da26..60e48d42 100644 --- a/src/server/utils/constants.ts +++ b/src/server/utils/constants.ts @@ -1,7 +1,6 @@ export const resourceName = 'pefcl'; export const DATABASE_PREFIX = 'pefcl_'; export const DEFAULT_CLEARING_NUMBER = 920; -export const PIN_CODE_LENGTH = 5; export const MS_ONE_DAY = 1000 * 60 * 60 * 24; export const MS_ONE_WEEK = MS_ONE_DAY * 7; diff --git a/web/src/components/ui/Fields/PinField.tsx b/web/src/components/ui/Fields/PinField.tsx index 1fa95e2d..b16ca2b9 100644 --- a/web/src/components/ui/Fields/PinField.tsx +++ b/web/src/components/ui/Fields/PinField.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; import { InputBaseProps, Stack, Typography } from '@mui/material'; -import { PIN_CODE_LENGTH } from '@utils/constants'; +import { PIN_CODE_LENGTH } from '@shared/constants'; import React, { ChangeEvent, useState } from 'react'; import Count from '../Count'; diff --git a/web/src/utils/constants.ts b/web/src/utils/constants.ts index e0ee7f1b..be428c24 100644 --- a/web/src/utils/constants.ts +++ b/web/src/utils/constants.ts @@ -1,13 +1,12 @@ import { Account, AccountRole, AccountType } from '@typings/Account'; -import { GetTransactionsResponse, TransactionType } from '../../../typings/Transaction'; -import { Invoice, InvoiceStatus } from '../../../typings/Invoice'; import dayjs from 'dayjs'; +import { Invoice, InvoiceStatus } from '@typings/Invoice'; +import { GetTransactionsResponse, TransactionType } from '@typings/Transaction'; export const resourceDefaultName = 'pefcl'; const now = dayjs(); -export const PIN_CODE_LENGTH = 5; export const DEFAULT_PAGINATION_LIMIT = 5; export const defaultWithdrawOptions = [500, 1000, 1500, 3000, 5000, 7500]; diff --git a/web/tsconfig.json b/web/tsconfig.json index 092a6787..e58dd45e 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -34,6 +34,7 @@ } }, "include": [ - "src/**/*" + "src/**/*", + "../shared/**/*" ] } \ No newline at end of file From ba38589f4fd4d655124d627d55ec83ba274f7734 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Thu, 15 Sep 2022 21:35:35 +0200 Subject: [PATCH 08/17] feat(cards): implemented cards --- config.json | 2 +- package.json | 1 + src/client/cl_events.ts | 10 + src/client/cl_exports.ts | 4 +- src/client/cl_integrations.ts | 2 + src/server/globals.server.ts | 18 +- src/server/server.ts | 30 ++ .../services/account/account.controller.ts | 5 +- .../services/account/account.service.ts | 31 +- .../broadcast/broadcast.controller.ts | 14 +- .../services/broadcast/broadcast.service.ts | 11 + src/server/services/card/card.controller.ts | 25 +- src/server/services/card/card.model.ts | 3 + src/server/services/card/card.service.ts | 74 +++- src/server/utils/frameworkIntegration.ts | 2 + typings/Account.ts | 15 +- typings/BankCard.ts | 3 + typings/Errors.ts | 1 + typings/Events.ts | 6 + typings/exports.ts | 4 +- web/src/App.tsx | 2 +- web/src/components/BankCard.tsx | 9 +- web/src/hooks/useExitListener.ts | 4 +- web/src/hooks/useKeyPress.ts | 15 + web/src/views/ATM/ATM.tsx | 334 +++++++++++++++--- web/src/views/Cards/components/BankCards.tsx | 6 +- 26 files changed, 554 insertions(+), 77 deletions(-) create mode 100644 web/src/hooks/useKeyPress.ts diff --git a/config.json b/config.json index d5262acb..994bccb3 100644 --- a/config.json +++ b/config.json @@ -85,7 +85,7 @@ ] }, "target": { - "type": "qtarget", + "type": "qb-target", "debug": false, "enabled": true, "bankZones": [ diff --git a/package.json b/package.json index 9a346dc7..4a0b6cfc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "setup": "yarn nx run-many --target=setup --all && yarn translations:pull && yarn translations:generate-index", "build": "yarn nx run-many --target=build --all", "lint": "yarn nx run-many --target=lint --all", + "tsc": "yarn nx run-many --target=tsc --all", "dev": "yarn nx run-many --target=dev --all", "tsc": "yarn nx run-many --target=tsc --all", "dev:ingame": "yarn nx run-many --target=dev:ingame --all", diff --git a/src/client/cl_events.ts b/src/client/cl_events.ts index 796385c2..deaed4a7 100644 --- a/src/client/cl_events.ts +++ b/src/client/cl_events.ts @@ -11,6 +11,7 @@ import { Broadcasts, NUIEvents, CashEvents, + CardEvents, } from '@typings/Events'; import { Invoice } from '@typings/Invoice'; import { Transaction } from '@typings/Transaction'; @@ -125,10 +126,19 @@ RegisterNuiProxy(SharedAccountEvents.GetUsers); RegisterNuiProxy(ExternalAccountEvents.Add); RegisterNuiProxy(ExternalAccountEvents.Get); +RegisterNuiProxy(AccountEvents.GetAtmAccount); RegisterNuiProxy(AccountEvents.WithdrawMoney); RegisterNuiProxy(AccountEvents.DepositMoney); RegisterNuiProxy(CashEvents.GetMyCash); +// Cards +RegisterNuiProxy(CardEvents.Get); +RegisterNuiProxy(CardEvents.Block); +RegisterNuiProxy(CardEvents.OrderPersonal); +RegisterNuiProxy(CardEvents.OrderShared); +RegisterNuiProxy(CardEvents.UpdatePin); +RegisterNuiProxy(CardEvents.GetInventoryCards); + RegisterCommand( 'bank-force-load', async () => { diff --git a/src/client/cl_exports.ts b/src/client/cl_exports.ts index a5655341..123b8d77 100644 --- a/src/client/cl_exports.ts +++ b/src/client/cl_exports.ts @@ -1,10 +1,12 @@ +import { NUIEvents } from '@typings/Events'; import { setBankIsOpen, setAtmIsOpen } from 'client'; import { createInvoice, depositMoney, giveCash, withdrawMoney } from 'functions'; const exp = global.exports; -exp('openBank', async () => { +exp('openBank', async (accountId: number) => { setBankIsOpen(true); + SendNUIMessage({ type: NUIEvents.SetCardId, payload: accountId }); }); exp('closeBank', async () => { diff --git a/src/client/cl_integrations.ts b/src/client/cl_integrations.ts index 7340659f..74fede07 100644 --- a/src/client/cl_integrations.ts +++ b/src/client/cl_integrations.ts @@ -1,3 +1,5 @@ +import { Card } from '@typings/BankCard'; +import { NUIEvents } from '@typings/Events'; import { setBankIsOpen, setAtmIsOpen } from 'client'; import cl_config from 'cl_config'; import { translations } from 'i18n'; diff --git a/src/server/globals.server.ts b/src/server/globals.server.ts index 45495be9..f2fe2e65 100644 --- a/src/server/globals.server.ts +++ b/src/server/globals.server.ts @@ -7,7 +7,7 @@ export const mockedResourceName = 'pefcl'; // TODO: Move this into package const convars = { - mysql_connection_string: 'mysql://dev:root@localhost/dev', + mysql_connection_string: 'mysql://root:root@127.0.0.1/QBCoreFramework_E05901?charset=utf8mb4', }; const players: any = { @@ -26,6 +26,10 @@ if (isMocking) { const ServerEmitter = new EventEmitter().setMaxListeners(25); const NetEmitter = new EventEmitter().setMaxListeners(25); + global.RegisterCommand = (cmd: string) => { + console.log('Registered command', cmd); + }; + global.LoadResourceFile = (_resourceName: string, fileName: string) => { const file = readFileSync(`${baseDir}/${fileName}`, 'utf-8'); return file; @@ -64,7 +68,7 @@ if (isMocking) { 'your-resource': { addCash: () => { console.log('global.server.ts: Adding cash ..'); - throw new Error('no funds'); + throw new Error('adding cash'); }, getCash: () => { console.log('global.server.ts: Getting cash ..'); @@ -72,7 +76,15 @@ if (isMocking) { }, removeCash: () => { console.log('global.server.ts: Removing cash ..'); - throw new Error('no funds'); + throw new Error('could not remove cash'); + }, + giveCard: () => { + console.log('global.server.ts: Giving card ..'); + throw new Error('giving card'); + }, + getCards: () => { + console.log('global.server.ts: Getting cards ..'); + return []; }, }, }); diff --git a/src/server/server.ts b/src/server/server.ts index 3cea7d70..b0e94b81 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -29,6 +29,7 @@ import { mockedResourceName } from './globals.server'; import { config } from './utils/server-config'; import { UserService } from './services/user/user.service'; import { container } from 'tsyringe'; +import { CardService } from './services/card/card.service'; const hotReloadConfig = { resourceName: GetCurrentResourceName(), @@ -77,6 +78,7 @@ if (isMocking) { app.post(...createEndpoint(UserEvents.GetUsers)); app.post(...createEndpoint(AccountEvents.GetAccounts)); + app.post(...createEndpoint(AccountEvents.GetAtmAccount)); app.post(...createEndpoint(AccountEvents.DeleteAccount)); app.post(...createEndpoint(AccountEvents.SetDefaultAccount)); app.post(...createEndpoint(AccountEvents.CreateAccount)); @@ -102,6 +104,7 @@ if (isMocking) { app.post(...createEndpoint(CardEvents.OrderPersonal)); app.post(...createEndpoint(CardEvents.UpdatePin)); app.post(...createEndpoint(CardEvents.Block)); + app.post(...createEndpoint(CardEvents.GetInventoryCards)); app.listen(port, async () => { mainLogger.child({ module: 'server' }).debug(`[MOCKSERVER]: listening on port: ${port}`); @@ -193,6 +196,33 @@ const debug = async () => { // }, // source: 0, // }); + + RegisterCommand( + 'card', + async (src: number) => { + const exps = exports; + const QBCore = await exps['qb-core']?.GetCoreObject(); + + await exps['qb-core'].RemoveItem('bank_card'); + + const item = { + name: 'bank_card', + label: 'Bank card', + weight: 1, + type: 'item', + image: 'visacard.png', + }; + + await exps['qb-core'].AddItem('bank_card', item); + + const cardService = container.resolve(CardService); + + const res = await cardService.giveCard(src, QBCore); + + console.log(res); + }, + false, + ); }; on(GeneralEvents.ResourceStarted, debug); diff --git a/src/server/services/account/account.controller.ts b/src/server/services/account/account.controller.ts index d75b89e7..4c1d5d11 100644 --- a/src/server/services/account/account.controller.ts +++ b/src/server/services/account/account.controller.ts @@ -15,6 +15,7 @@ import { AddToUniqueAccountInput, RemoveFromUniqueAccountInput, UpdateBankBalanceByNumberInput, + GetATMAccountInput, } from '@typings/Account'; import { AccountEvents, @@ -125,11 +126,7 @@ export class AccountController { @Export(ServerExports.WithdrawCash) @NetPromise(AccountEvents.WithdrawMoney) async withdrawMoney(req: Request, res: Response) { - const accountId = req.data.accountId; - try { - accountId && - (await this._auth.isAuthorizedAccount(accountId, req.source, [AccountRole.Admin])); await this._accountService.handleWithdrawMoney(req); res({ status: 'ok', data: {} }); } catch (err) { diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index adcc72a5..b63aae2c 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -31,6 +31,7 @@ import { AccountErrors, AuthorizationErrors, BalanceErrors, + CardErrors, GenericErrors, UserErrors, } from '@typings/Errors'; @@ -38,6 +39,7 @@ import { SharedAccountDB } from '@services/accountShared/sharedAccount.db'; import { AccountEvents, Broadcasts } from '@server/../../typings/Events'; import { getFrameworkExports } from '@server/utils/frameworkIntegration'; import { Transaction } from 'sequelize/types'; +import { CardDB } from '../card/card.db'; const logger = mainLogger.child({ module: 'accounts' }); const { enabled = false, syncInitialBankBalance = false } = config.frameworkIntegration ?? {}; @@ -46,6 +48,7 @@ const isFrameworkIntegrationEnabled = enabled; @singleton() export class AccountService { + _cardDB: CardDB; _accountDB: AccountDB; _sharedAccountDB: SharedAccountDB; _cashService: CashService; @@ -58,7 +61,9 @@ export class AccountService { userService: UserService, cashService: CashService, transactionService: TransactionService, + cardDB: CardDB, ) { + this._cardDB = cardDB; this._accountDB = accountDB; this._sharedAccountDB = sharedAccountDB; this._cashService = cashService; @@ -460,25 +465,37 @@ export class AccountService { } async handleWithdrawMoney(req: Request) { - logger.silly(`"${req.source}" withdrawing "${req.data.amount}".`); - const amount = req.data.amount; + const { accountId, amount, cardId, cardPin } = req.data; + logger.silly(`"${req.source}" withdrawing "${amount}".`); if (amount <= 0) { throw new ServerError(GenericErrors.BadInput); } - /* Only run the export when account is the default(?). Not sure about this. */ const t = await sequelize.transaction(); try { - const targetAccount = req.data.accountId - ? await this._accountDB.getAccountById(req.data.accountId) - : await this.getDefaultAccountBySource(req.source); + /* If framework is enabled, do a card check, otherwise continue. */ + if (isFrameworkIntegrationEnabled) { + const exports = getFrameworkExports(); + const cards = exports.getCards(req.source); + const selectedCard = cards.find((card) => card.id === cardId); + + if (!selectedCard) { + throw new Error('User does not have selected card in inventory.'); + } + + const card = await this._cardDB.getById(selectedCard.id); + if (card?.getDataValue('pin') !== cardPin) { + throw new Error(CardErrors.InvalidPin); + } + } + + const targetAccount = await this._accountDB.getAccountById(accountId); if (!targetAccount) { throw new ServerError(GenericErrors.NotFound); } - const accountId = targetAccount.getDataValue('id') ?? 0; const currentAccountBalance = targetAccount.getDataValue('balance'); if (currentAccountBalance < amount) { diff --git a/src/server/services/broadcast/broadcast.controller.ts b/src/server/services/broadcast/broadcast.controller.ts index e34981b0..c99ad577 100644 --- a/src/server/services/broadcast/broadcast.controller.ts +++ b/src/server/services/broadcast/broadcast.controller.ts @@ -1,11 +1,18 @@ import { Account } from '@server/../../typings/Account'; +import { Card } from '@server/../../typings/BankCard'; import { Cash } from '@server/../../typings/Cash'; -import { AccountEvents, CashEvents, TransactionEvents } from '@server/../../typings/Events'; import { Transaction } from '@server/../../typings/Transaction'; import { Controller } from '@server/decorators/Controller'; import { Event, EventListener } from '@server/decorators/Event'; import { BroadcastService } from './broadcast.service'; +import { + AccountEvents, + CardEvents, + CashEvents, + TransactionEvents, +} from '@server/../../typings/Events'; + @Controller('Broadcast') @EventListener() export class BroadcastController { @@ -43,4 +50,9 @@ export class BroadcastController { async onNewTransaction(transaction: Transaction) { this.broadcastService.broadcastTransaction(transaction); } + + @Event(CardEvents.NewCard) + async onNewCard(card: Card) { + this.broadcastService.broadcastNewCard(card); + } } diff --git a/src/server/services/broadcast/broadcast.service.ts b/src/server/services/broadcast/broadcast.service.ts index 3da23e94..cca8d3b1 100644 --- a/src/server/services/broadcast/broadcast.service.ts +++ b/src/server/services/broadcast/broadcast.service.ts @@ -7,6 +7,7 @@ import { TransactionDB } from '../transaction/transaction.db'; import { Account, AccountType } from '@server/../../typings/Account'; import { Cash } from '@server/../../typings/Cash'; import { AccountService } from '../account/account.service'; +import { Card } from '@server/../../typings/BankCard'; const logger = mainLogger.child({ module: 'broadcastService' }); @@ -36,6 +37,16 @@ export class BroadcastService { emitNet(Broadcasts.UpdatedAccount, user?.getSource(), account); } + async broadcastNewCard(card: Card) { + logger.silly(`Broadcasted new card:`); + logger.silly(JSON.stringify(card)); + + const user = this._userService.getUserByIdentifier(card.holderCitizenId); + if (!user) return; + + emitNet(Broadcasts.NewCard, user?.getSource(), card); + } + async broadcastTransaction(transaction: Transaction) { logger.silly(`Broadcasted transaction:`); logger.silly(JSON.stringify(transaction)); diff --git a/src/server/services/card/card.controller.ts b/src/server/services/card/card.controller.ts index 8f2945c0..cd5af566 100644 --- a/src/server/services/card/card.controller.ts +++ b/src/server/services/card/card.controller.ts @@ -1,12 +1,14 @@ import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; +import { GetATMAccountInput, GetATMAccountResponse } from '@server/../../typings/Account'; import { BlockCardInput, Card, CreateCardInput, GetCardInput, + InventoryCard, UpdateCardPinInput, } from '@server/../../typings/BankCard'; -import { CardEvents } from '@typings/Events'; +import { AccountEvents, CardEvents } from '@typings/Events'; import { Request, Response } from '@typings/http'; import { Controller } from '../../decorators/Controller'; import { EventListener } from '../../decorators/Event'; @@ -21,6 +23,16 @@ export class CardController { this.cardService = cardService; } + @NetPromise(AccountEvents.GetAtmAccount) + async getAtmAccount(req: Request, res: Response) { + try { + const result = await this.cardService.getAccountByCard(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + @NetPromise(CardEvents.OrderPersonal) async orderPersonalAccount(req: Request, res: Response) { try { @@ -60,4 +72,15 @@ export class CardController { res({ status: 'error', errorMsg: error.message }); } } + + /* Return cards from player inventory to be selected at ATM */ + @NetPromise(CardEvents.GetInventoryCards) + async getInventoryCards(req: Request, res: Response) { + try { + const result = await this.cardService.getInventoryCards(req); + res({ status: 'ok', data: result }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } } diff --git a/src/server/services/card/card.model.ts b/src/server/services/card/card.model.ts index de6bf1ed..c4675bcc 100644 --- a/src/server/services/card/card.model.ts +++ b/src/server/services/card/card.model.ts @@ -23,6 +23,9 @@ CardModel.init( holder: { type: DataTypes.STRING, }, + holderCitizenId: { + type: DataTypes.STRING, + }, isBlocked: { type: DataTypes.BOOLEAN, defaultValue: false, diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts index 4ddbf86d..ef814428 100644 --- a/src/server/services/card/card.service.ts +++ b/src/server/services/card/card.service.ts @@ -6,18 +6,23 @@ import { Request } from '@typings/http'; import { CardDB } from './card.db'; import { sequelize } from '@server/utils/pool'; import { AccountService } from '../account/account.service'; -import { CardErrors, GenericErrors } from '@server/../../typings/Errors'; +import { CardErrors, GenericErrors, UserErrors } from '@server/../../typings/Errors'; import i18next from '@utils/i18n'; import { BlockCardInput, Card, CreateCardInput, + InventoryCard, UpdateCardPinInput, } from '@server/../../typings/BankCard'; import { AccountDB } from '../account/account.db'; import { PIN_CODE_LENGTH } from '@shared/constants'; +import { GetATMAccountResponse, GetATMAccountInput } from '@server/../../typings/Account'; +import { CardEvents } from '@server/../../typings/Events'; +import { getFrameworkExports } from '@server/utils/frameworkIntegration'; const logger = mainLogger.child({ module: 'card' }); +const isFrameworkIntegrationEnabled = config?.frameworkIntegration?.enabled; @singleton() export class CardService { @@ -43,6 +48,65 @@ export class CardService { return cards.map((card) => card.toJSON()); } + async getInventoryCards(req: Request): Promise { + const user = this.userService.getUser(req.source); + + if (!user) { + throw new Error(UserErrors.NotFound); + } + + if (!isFrameworkIntegrationEnabled) { + logger.error('Phsyical cards are not available without FrameworkIntegration enabled.'); + return []; + } + + const exports = getFrameworkExports(); + return exports.getCards(user.getSource()); + } + + async getAccountByCard(req: Request): Promise { + logger.silly('Getting account by card.'); + + const { cardId, pin } = req.data; + const card = await this.cardDB.getById(cardId); + + if (!card) { + logger.error('Card not found'); + throw new Error(GenericErrors.NotFound); + } + + if (card.getDataValue('isBlocked')) { + logger.error('The card is blocked'); + throw new Error(CardErrors.Blocked); + } + + if (pin !== card.getDataValue('pin')) { + logger.error('Invalid pin'); + throw new Error(CardErrors.InvalidPin); + } + + const account = await this.accountDB.getAccountById(card.getDataValue('accountId') ?? -1); + + if (!account) { + logger.error('Card is not bound to any account'); + throw new Error(GenericErrors.NotFound); + } + + logger.error('Returning account!'); + return { account: account?.toJSON(), card: card.toJSON() }; + } + + async giveCard(src: number, card: Card) { + if (!isFrameworkIntegrationEnabled) { + logger.error('Could not give card to player.'); + logger.error('Phsyical cards are not available without FrameworkIntegration enabled.'); + return; + } + + const exports = getFrameworkExports(); + return exports.giveCard(src, card); + } + async blockCard(req: Request) { const { cardId, pin } = req.data; const card = await this.cardDB.getById(cardId); @@ -122,6 +186,7 @@ export class CardService { { pin: pin, holder: user.name, + holderCitizenId: user.getIdentifier(), accountId: account.getDataValue('id'), }, t, @@ -136,6 +201,13 @@ export class CardService { }, }); + t.afterCommit(() => { + logger.silly(`Emitting ${CardEvents.NewCard}`); + emit(CardEvents.NewCard, { ...card.toJSON() }); + }); + + this.giveCard(req.source, card.toJSON()); + t.commit(); return card.toJSON(); } catch (err) { diff --git a/src/server/utils/frameworkIntegration.ts b/src/server/utils/frameworkIntegration.ts index f72406d2..71930ec4 100644 --- a/src/server/utils/frameworkIntegration.ts +++ b/src/server/utils/frameworkIntegration.ts @@ -13,6 +13,8 @@ const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [ 'removeCash', 'getCash', 'getBank', + 'giveCard', + 'getCards' ]; export const validateResourceExports = (resourceExports: FrameworkIntegrationExports): boolean => { diff --git a/typings/Account.ts b/typings/Account.ts index 9ebc3382..049925ee 100644 --- a/typings/Account.ts +++ b/typings/Account.ts @@ -1,3 +1,5 @@ +import { Card } from './BankCard'; + export enum AccountType { Personal = 'personal', Shared = 'shared', @@ -40,6 +42,15 @@ export interface Account { createdAt?: string; } +export interface GetATMAccountInput { + pin: number; + cardId: number; +} + +export interface GetATMAccountResponse { + card: Card; + account: Account; +} export interface CreateAccountInput { accountName: string; ownerIdentifier: string; @@ -95,7 +106,9 @@ export type TransactionAccount = Pick; export interface ATMInput { amount: number; message: string; - accountId?: number; + accountId: number; + cardId?: number; + cardPin?: number; } export interface ExternalAccount { diff --git a/typings/BankCard.ts b/typings/BankCard.ts index db6dcea1..ca2faf29 100644 --- a/typings/BankCard.ts +++ b/typings/BankCard.ts @@ -11,6 +11,7 @@ export interface Card { // Static holder: string; + holderCitizenId: string; number: string; // Timestamps @@ -18,6 +19,8 @@ export interface Card { createdAt?: string | number | Date; } +export type InventoryCard = Pick; + export interface GetCardInput { accountId: number; } diff --git a/typings/Errors.ts b/typings/Errors.ts index 2657854b..9ef61c3e 100644 --- a/typings/Errors.ts +++ b/typings/Errors.ts @@ -20,6 +20,7 @@ export enum CardErrors { MissingConfigCost = 'MissingConfigCost', FailedToCreate = 'FailedToCreate', InvalidPin = 'InvalidPin', + Blocked = 'Blocked', } export enum UserErrors { diff --git a/typings/Events.ts b/typings/Events.ts index 77342879..f1408520 100644 --- a/typings/Events.ts +++ b/typings/Events.ts @@ -13,9 +13,12 @@ export enum UserEvents { export enum NUIEvents { Loaded = 'pefcl:nuiHasLoaded', Unloaded = 'pefcl:nuiHasUnloaded', + SetCardId = 'pefcl:nuiSetCardId', + SetCards = 'pefcl:nuiSetCards', } export enum AccountEvents { + GetAtmAccount = 'pefcl:getAtmAccount', GetAccounts = 'pefcl:getAccounts', CreateAccount = 'pefcl:createAccount', RenameAccount = 'pefcl:renameAccount', @@ -51,6 +54,7 @@ export enum Broadcasts { NewAccountBalance = 'pefcl:newAccountBalanceBroadcast', NewDefaultAccountBalance = 'pefcl:newDefaultAccountBalance', NewCashAmount = 'pefcl:newCashAmount', + NewCard = 'pefcl:newCardBroadcast', } export enum TransactionEvents { @@ -84,4 +88,6 @@ export enum CardEvents { OrderPersonal = 'pefcl:orderPersonalCard', Block = 'pefcl:blockCard', UpdatePin = 'pefcl:updatePin', + NewCard = 'pefcl:newCard', + GetInventoryCards = 'pefcl:getInventoryCards', } diff --git a/typings/exports.ts b/typings/exports.ts index 7004521a..5c236917 100644 --- a/typings/exports.ts +++ b/typings/exports.ts @@ -1,6 +1,6 @@ /* Exports used with framework integrations */ -import { OnlineUser } from './user'; +import { Card, InventoryCard } from './BankCard'; export interface FrameworkIntegrationExports { /* Cash exports */ @@ -15,6 +15,8 @@ export interface FrameworkIntegrationExports { * This export should probably remove old bank balance as well. */ getBank: (source: number) => number; + giveCard: (source: number, card: Card) => void; + getCards: (source: number) => InventoryCard[]; } export type FrameworkIntegrationFunction = keyof FrameworkIntegrationExports; diff --git a/web/src/App.tsx b/web/src/App.tsx index 155a821c..7f07b006 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -78,7 +78,7 @@ const App: React.FC = () => { useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsAtmVisible(data)); const { i18n } = useTranslation(); - useExitListener(); + useExitListener(isVisible); useEffect(() => { i18n.changeLanguage(config?.general?.language).catch((e) => console.error(e)); diff --git a/web/src/components/BankCard.tsx b/web/src/components/BankCard.tsx index a847750a..a4cc97ad 100644 --- a/web/src/components/BankCard.tsx +++ b/web/src/components/BankCard.tsx @@ -1,5 +1,5 @@ import { Stack } from '@mui/material'; -import { Card } from '@typings/BankCard'; +import { Card, InventoryCard } from '@typings/BankCard'; import theme from '@utils/theme'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,14 +41,15 @@ const StyledIcon = styled(MasterCardIcon)` `; interface BankCardProps { - card: Card; + card: Card | InventoryCard; + isBlocked?: boolean; selected?: boolean; } -const BankCard = ({ card, selected = false }: BankCardProps) => { +const BankCard = ({ card, selected = false, isBlocked = false }: BankCardProps) => { const { t } = useTranslation(); return ( - + {card.number} diff --git a/web/src/hooks/useExitListener.ts b/web/src/hooks/useExitListener.ts index afe423e7..b133f728 100644 --- a/web/src/hooks/useExitListener.ts +++ b/web/src/hooks/useExitListener.ts @@ -5,10 +5,10 @@ import { GeneralEvents } from '@typings/Events'; const LISTENED_KEYS = ['Escape']; -export const useExitListener = () => { +export const useExitListener = (enabled: boolean) => { useEffect(() => { const keyHandler = (e: KeyboardEvent) => { - if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser()) { + if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser() && enabled) { fetchNui(GeneralEvents.CloseUI); } }; diff --git a/web/src/hooks/useKeyPress.ts b/web/src/hooks/useKeyPress.ts new file mode 100644 index 00000000..acd5254c --- /dev/null +++ b/web/src/hooks/useKeyPress.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; + +export const useKeyDown = (keys: string[], callback: () => void) => { + useEffect(() => { + const keyHandler = (e: KeyboardEvent) => { + if (keys.includes(e.code)) { + callback(); + } + }; + + window.addEventListener('keydown', keyHandler); + + return () => window.removeEventListener('keydown', keyHandler); + }, [keys, callback]); +}; diff --git a/web/src/views/ATM/ATM.tsx b/web/src/views/ATM/ATM.tsx index 6ff706a9..aede079f 100644 --- a/web/src/views/ATM/ATM.tsx +++ b/web/src/views/ATM/ATM.tsx @@ -1,21 +1,26 @@ import Button from '@components/ui/Button'; -import { Heading2, Heading6 } from '@components/ui/Typography/Headings'; -import { accountsAtom, defaultAccountBalance } from '@data/accounts'; +import { Heading2, Heading4, Heading6 } from '@components/ui/Typography/Headings'; import styled from '@emotion/styled'; import { useConfig } from '@hooks/useConfig'; -import { Paper, Stack } from '@mui/material'; -import { ATMInput } from '@typings/Account'; -import { AccountEvents } from '@typings/Events'; +import { Alert, Paper, Stack } from '@mui/material'; +import { Account, ATMInput, GetATMAccountInput } from '@typings/Account'; +import { AccountEvents, CardEvents } from '@typings/Events'; import { defaultWithdrawOptions } from '@utils/constants'; import { formatMoney } from '@utils/currency'; import { fetchNui } from '@utils/fetchNui'; import theme from '@utils/theme'; import { AnimatePresence } from 'framer-motion'; -import { useAtom } from 'jotai'; -import React, { useState } from 'react'; +import React, { FormEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { useNuiEvent } from '@hooks/useNuiEvent'; +import { Card, InventoryCard } from '@typings/BankCard'; +import { useKeyDown } from '@hooks/useKeyPress'; +import { CardErrors } from '@typings/Errors'; +import { PIN_CODE_LENGTH } from '@shared/constants'; +import BankCard from '@components/BankCard'; +import { ErrorRounded } from '@mui/icons-material'; +import PinField from '@components/ui/Fields/PinField'; const AnimationContainer = styled.div` position: absolute; @@ -36,7 +41,7 @@ const AccountBalance = styled(Heading6)` `; const Header = styled(Stack)` - margin-bottom: ${theme.spacing(7)}; + margin-bottom: ${theme.spacing(5)}; `; const WithdrawText = styled(Heading6)` @@ -51,60 +56,299 @@ const WithdrawContainer = styled.div` grid-column-gap: ${theme.spacing(1.5)}; `; +const CardWrapper = styled.div` + min-width: 15rem; +`; + +type BankState = 'select-card' | 'enter-pin' | 'withdraw'; + const ATM = () => { const { t } = useTranslation(); const config = useConfig(); - const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions; + const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [accountBalance] = useAtom(defaultAccountBalance); - const [, updateAccounts] = useAtom(accountsAtom); - + const [account, setAccount] = useState(); const [isOpen, setIsOpen] = useState(false); useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsOpen(data)); + const [selectedCard, setSelectedCard] = useState(); + const [cards, setCards] = useState([]); + const [state, setState] = useState('select-card'); + const [pin, setPin] = useState(''); + + const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions; + + const handleClose = () => { + setError(''); + setPin(''); + setAccount(undefined); + setState('select-card'); + }; + + const handleBack = () => { + setError(''); + setPin(''); + if (state === 'enter-pin') { + setState('select-card'); + } + }; + + useKeyDown(['Escape'], handleBack); + + const handleVisibility = (isOpen: boolean) => { + setIsOpen(isOpen); + + if (!isOpen) { + handleClose(); + } + }; + + useEffect(() => { + const updateCards = async () => { + try { + const cards = await fetchNui(CardEvents.GetInventoryCards); + if (!cards) { + throw new Error('No cards available'); + } + setCards(cards); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } else { + setError(t('Something went wrong, please try again later.')); + } + } + }; + + updateCards(); + }, [t]); + + const input = { + cardId: selectedCard?.id ?? 0, + pin: parseInt(pin, 10), + }; + + const handleUpdateBalance = async () => { + setError(''); + const response = await fetchNui<{ account: Account; card: Card }, GetATMAccountInput>( + AccountEvents.GetAtmAccount, + input, + ); + + if (!response) { + return; + } + + const { card, account } = response; + setSelectedCard(card); + setAccount(account); + }; + + const handleWithdraw = async (amount: number) => { + if (!account) { + return; + } - const handleWithdraw = (amount: number) => { const payload: ATMInput = { amount, - message: 'Withdrew ' + amount + ' from an ATM.', + cardId: selectedCard?.id, + cardPin: parseInt(pin, 10), + accountId: account?.id, + message: t('Withdrew {{amount}} from an ATM with card {{cardNumber}}.', { + amount, + cardNumber: selectedCard?.number ?? 'unknown', + }), }; setIsLoading(true); - // TODO: Update this with cards implementation - fetchNui(AccountEvents.WithdrawMoney, payload) - .then(() => updateAccounts()) - .finally(() => setIsLoading(false)); + + try { + setError(''); + await fetchNui(AccountEvents.WithdrawMoney, payload); + await handleUpdateBalance(); + } catch (error) { + if (error instanceof Error) { + if (error.message === CardErrors.InvalidPin) { + setError(t('Invalid pin')); + return; + } + + if (error.message === CardErrors.Blocked) { + setError(t('The card is blocked')); + return; + } + + setError(error.message); + } else { + setError(t('Something went wrong, please try again later.')); + } + } + + setIsLoading(false); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (pin.length === PIN_CODE_LENGTH && selectedCard?.id) { + try { + setError(''); + const response = await fetchNui<{ account: Account; card: Card }, GetATMAccountInput>( + AccountEvents.GetAtmAccount, + input, + ); + + if (!response) { + return; + } + + const { card, account } = response; + setSelectedCard(card); + setAccount(account); + setState('withdraw'); + } catch (error) { + if (error instanceof Error) { + if (error.message === CardErrors.InvalidPin) { + setError(t('Invalid pin')); + return; + } + + if (error.message === CardErrors.Blocked) { + setError(t('The card is blocked')); + return; + } + + setError(error.message); + } else { + setError(t('Something went wrong, please try again later.')); + } + } + } + }; + + const handleSelectCard = (card: InventoryCard) => { + setSelectedCard(card); + setState('enter-pin'); }; return ( - - {isOpen && ( - - - -
- {t('Account balance')} - {formatMoney(accountBalance ?? 0, config.general)} -
- - {t('Quick withdraw')} - - {withdrawOptions.map((value) => ( - +
+ + + {error && ( + } + color="error" + sx={{ margin: '0.5rem -1.5rem -1.5rem' }} > - {formatMoney(value, config.general)} - - ))} - -
- - - )} - + {error} + + )} +
+ + + )} + + + + {isOpen && state === 'withdraw' && ( + + + +
+ {t('Account balance')} + {formatMoney(account?.balance ?? 0, config.general)} +
+ + {t('Quick withdraw')} + + {withdrawOptions.map((value) => ( + + ))} + + + {error && ( + } color="error" sx={{ marginTop: '1rem' }}> + {error} + + )} +
+
+
+ )} +
+ ); }; diff --git a/web/src/views/Cards/components/BankCards.tsx b/web/src/views/Cards/components/BankCards.tsx index 6a3f364f..a607a5ae 100644 --- a/web/src/views/Cards/components/BankCards.tsx +++ b/web/src/views/Cards/components/BankCards.tsx @@ -136,6 +136,7 @@ const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps }; const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId); + const isAffordable = (selectedAccount?.balance ?? 0) > cost; return ( @@ -234,10 +235,7 @@ const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps - From 6e909fe395753a6b60c13f6661f6ffbeaf7a1d4d Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Fri, 16 Sep 2022 22:35:36 +0200 Subject: [PATCH 09/17] fix: exitListener, on/off --- web/src/hooks/useExitListener.ts | 2 +- web/src/views/ATM/ATM.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useExitListener.ts b/web/src/hooks/useExitListener.ts index b133f728..f3149282 100644 --- a/web/src/hooks/useExitListener.ts +++ b/web/src/hooks/useExitListener.ts @@ -16,5 +16,5 @@ export const useExitListener = (enabled: boolean) => { window.addEventListener('keydown', keyHandler); return () => window.removeEventListener('keydown', keyHandler); - }, []); + }, [enabled]); }; diff --git a/web/src/views/ATM/ATM.tsx b/web/src/views/ATM/ATM.tsx index aede079f..ee8c67e3 100644 --- a/web/src/views/ATM/ATM.tsx +++ b/web/src/views/ATM/ATM.tsx @@ -21,6 +21,7 @@ import { PIN_CODE_LENGTH } from '@shared/constants'; import BankCard from '@components/BankCard'; import { ErrorRounded } from '@mui/icons-material'; import PinField from '@components/ui/Fields/PinField'; +import { useExitListener } from '@hooks/useExitListener'; const AnimationContainer = styled.div` position: absolute; @@ -76,6 +77,8 @@ const ATM = () => { const [state, setState] = useState('select-card'); const [pin, setPin] = useState(''); + useExitListener(state !== 'enter-pin'); + const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions; const handleClose = () => { From 9984949301210c58a476f0ee380b0ad35019c66f Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Wed, 21 Sep 2022 17:27:55 +0200 Subject: [PATCH 10/17] fix: generate translations index on download --- .github/workflows/prerelease.yml | 1 - .github/workflows/release.yml | 3 --- package.json | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index b5de8d81..4295c18d 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -40,7 +40,6 @@ jobs: LOCALAZY_WRITE_KEY: ${{ secrets.LOCALAZY_WRITE_KEY }} run: | yarn translations:pull - yarn translations:generate-index - name: Install src deps working-directory: src diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73a42cad..2729f403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,9 +37,6 @@ jobs: LOCALAZY_READ_KEY: a8269809765126758267-f01743c76d6e9e434d7b4c6322938eefce8d82a559b57efc2acfcf4531d46089 run: yarn translations:pull - - name: Generate translations - run: yarn translations:generate-index - - name: Install src deps working-directory: src run: yarn --frozen-lockfile diff --git a/package.json b/package.json index 4a0b6cfc..187c14ee 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "postinstall": "husky install && yarn setup", "translations:generate": "yarn i18next", "translations:generate-index": "node ./scripts/generateLocales.js", - "translations:pull": "localazy download", + "translations:pull": "localazy download && node ./scripts/generateLocales.js", "translations:push": "localazy upload -w $LOCALAZY_WRITE_KEY -r $LOCALAZY_READ_KEY", "setup": "yarn nx run-many --target=setup --all && yarn translations:pull && yarn translations:generate-index", "build": "yarn nx run-many --target=build --all", From bbc23810163b5e7034b52df67a03b236af3ddf99 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Wed, 21 Sep 2022 18:17:09 +0200 Subject: [PATCH 11/17] fix(sync): await client load before emitting playerLoaded --- shared/constants.ts | 1 + src/client/cl_events.ts | 2 ++ .../services/account/account.service.ts | 1 - src/server/services/user/user.controller.ts | 8 ++++- src/server/services/user/user.module.ts | 5 ++++ src/server/services/user/user.service.ts | 29 +++++++++++++++---- typings/Events.ts | 1 + 7 files changed, 39 insertions(+), 8 deletions(-) diff --git a/shared/constants.ts b/shared/constants.ts index dab9bb47..d21fe449 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -1 +1,2 @@ export const PIN_CODE_LENGTH = 4; +export const CHECK_PLAYER_LOADED_INTERVAL = 15; // ms; diff --git a/src/client/cl_events.ts b/src/client/cl_events.ts index deaed4a7..389c59cb 100644 --- a/src/client/cl_events.ts +++ b/src/client/cl_events.ts @@ -25,6 +25,8 @@ const npwdExports = global.exports['npwd']; const useFrameworkIntegration = config.frameworkIntegration?.enabled; let hasNUILoaded = false; +emitNet(UserEvents.LoadClient); + RegisterNuiCB(NUIEvents.Loaded, () => { console.debug('NUI has loaded.'); hasNUILoaded = true; diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index b63aae2c..2c1c5cb5 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -876,7 +876,6 @@ export class AccountService { type, accountName: name, ownerIdentifier: identifier, - isDefault: true, }); const json = account.toJSON(); diff --git a/src/server/services/user/user.controller.ts b/src/server/services/user/user.controller.ts index bb3a0668..cfd05f48 100644 --- a/src/server/services/user/user.controller.ts +++ b/src/server/services/user/user.controller.ts @@ -1,5 +1,5 @@ import { Controller } from '@decorators/Controller'; -import { EventListener, Event } from '@decorators/Event'; +import { EventListener, Event, NetEvent } from '@decorators/Event'; import { NetPromise, PromiseEventListener } from '@decorators/NetPromise'; import { ServerExports } from '@server/../../typings/exports/server'; import { Export, ExportListener } from '@server/decorators/Export'; @@ -48,6 +48,12 @@ export class UserController { res({ status: 'ok', data: list }); } + @NetEvent(UserEvents.LoadClient) + async loadClient() { + const src = source; + this._userService.loadClient(src); + } + @Event('playerJoining') playerJoining() { if (config.frameworkIntegration?.enabled) return; diff --git a/src/server/services/user/user.module.ts b/src/server/services/user/user.module.ts index 4165dddd..2dc6df30 100644 --- a/src/server/services/user/user.module.ts +++ b/src/server/services/user/user.module.ts @@ -4,6 +4,7 @@ export class UserModule { private readonly _source: number; private readonly _identifier: string; public readonly name: string; + public isClientLoaded: boolean; constructor(user: OnlineUser) { this._source = user.source; @@ -18,4 +19,8 @@ export class UserModule { getIdentifier() { return this._identifier; } + + loadClient() { + this.isClientLoaded = true; + } } diff --git a/src/server/services/user/user.service.ts b/src/server/services/user/user.service.ts index 56969f95..c52513da 100644 --- a/src/server/services/user/user.service.ts +++ b/src/server/services/user/user.service.ts @@ -6,16 +6,25 @@ import { OnlineUser, UserDTO } from '../../../../typings/user'; import { getPlayerIdentifier, getPlayerName } from '../../utils/misc'; import { UserModule } from './user.module'; import { UserEvents } from '@server/../../typings/Events'; +import { CHECK_PLAYER_LOADED_INTERVAL } from '@shared/constants'; const logger = mainLogger.child({ module: 'user' }); @singleton() export class UserService { private readonly usersBySource: Map; // Player class + private loadedSources: number[]; + constructor() { + this.loadedSources = []; this.usersBySource = new Map(); } + loadClient(source: number) { + logger.debug('Loaded client for source: ' + source); + this.loadedSources.push(source); + } + getAllUsers() { return this.usersBySource; } @@ -55,12 +64,8 @@ export class UserService { const user = new UserModule(data); this.usersBySource.set(user.getSource(), user); - logger.debug(`Player loaded. Emitting: ${UserEvents.Loaded}`); - - setImmediate(() => { - emit(UserEvents.Loaded, data); - emitNet(UserEvents.Loaded, data.source, data); - }); + logger.debug('Player loaded on server.'); + this.emitLoadedPlayer(user, data); } async unloadPlayer(source: number) { @@ -86,4 +91,16 @@ export class UserService { return this.loadPlayer(user); } + + emitLoadedPlayer(user: UserModule, data: OnlineUser) { + const interval = setInterval(() => { + const isLoaded = this.loadedSources.includes(user.getSource()); + if (isLoaded) { + logger.debug(`Player loaded on client. Emitting: ${UserEvents.Loaded}`); + emit(UserEvents.Loaded, data); + emitNet(UserEvents.Loaded, data.source, data); + clearInterval(interval); + } + }, CHECK_PLAYER_LOADED_INTERVAL); + } } diff --git a/typings/Events.ts b/typings/Events.ts index f1408520..8a187659 100644 --- a/typings/Events.ts +++ b/typings/Events.ts @@ -8,6 +8,7 @@ export enum UserEvents { GetUsers = 'pefcl:userEventsGetUsers', Loaded = 'pefcl:userLoaded', Unloaded = 'pefcl:userUnloaded', + LoadClient = 'pefcl:loadClient', } export enum NUIEvents { From 5e4b1519f0bf4abafab225a1ff5a8dffc9c2af99 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Wed, 21 Sep 2022 19:38:00 +0200 Subject: [PATCH 12/17] feat(cards): isCardsEnabled config --- config.json | 5 +- package.json | 3 +- .../services/account/account.service.ts | 8 ++- src/server/services/boot/boot.service.ts | 22 ++++++- src/server/services/card/card.service.ts | 37 ++++++++---- src/server/utils/frameworkIntegration.ts | 20 ++++--- typings/config.ts | 1 + web/src/components/Sidebar.tsx | 7 ++- web/src/views/ATM/ATM.tsx | 58 +++++++++++++------ web/src/views/Withdraw/Withdraw.tsx | 4 ++ 10 files changed, 118 insertions(+), 47 deletions(-) diff --git a/config.json b/config.json index 994bccb3..def3219b 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,8 @@ "frameworkIntegration": { "enabled": false, "resource": "your-resource", - "syncInitialBankBalance": true + "syncInitialBankBalance": true, + "isCardsEnabled": false }, "database": { "profileQueries": true @@ -85,7 +86,7 @@ ] }, "target": { - "type": "qb-target", + "type": "qtarget", "debug": false, "enabled": true, "bankZones": [ diff --git a/package.json b/package.json index 187c14ee..bf905681 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "lint": "yarn nx run-many --target=lint --all", "tsc": "yarn nx run-many --target=tsc --all", "dev": "yarn nx run-many --target=dev --all", - "tsc": "yarn nx run-many --target=tsc --all", "dev:ingame": "yarn nx run-many --target=dev:ingame --all", "dev:mobile": "yarn nx run-many --target=dev:mobile --all", "pre-release": "yarn build && sh ./scripts/prerelease.sh", @@ -40,4 +39,4 @@ "dependencies": { "i18next-parser": "^6.0.0" } -} \ No newline at end of file +} diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index 2c1c5cb5..c3db9552 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -42,7 +42,11 @@ import { Transaction } from 'sequelize/types'; import { CardDB } from '../card/card.db'; const logger = mainLogger.child({ module: 'accounts' }); -const { enabled = false, syncInitialBankBalance = false } = config.frameworkIntegration ?? {}; +const { + enabled = false, + syncInitialBankBalance = false, + isCardsEnabled = false, +} = config.frameworkIntegration ?? {}; const { firstAccountStartBalance } = config.accounts ?? {}; const isFrameworkIntegrationEnabled = enabled; @@ -475,7 +479,7 @@ export class AccountService { const t = await sequelize.transaction(); try { /* If framework is enabled, do a card check, otherwise continue. */ - if (isFrameworkIntegrationEnabled) { + if (isFrameworkIntegrationEnabled && isCardsEnabled) { const exports = getFrameworkExports(); const cards = exports.getCards(req.source); const selectedCard = cards.find((card) => card.id === cardId); diff --git a/src/server/services/boot/boot.service.ts b/src/server/services/boot/boot.service.ts index ca911a33..b4aaf896 100644 --- a/src/server/services/boot/boot.service.ts +++ b/src/server/services/boot/boot.service.ts @@ -43,14 +43,27 @@ export class BootService { logger.error(error); if (error instanceof Error && error.message.includes('No such export')) { - logger.error( - 'Check your starting order. The framework integration library needs to be started before PEFCL!', - ); + if (config.frameworkIntegration.isCardsEnabled && error.message.includes('Card')) { + logger.error(' '); + logger.error( + 'This framework does not seem to support cards. Make sure your resource is exporting the required exports!', + ); + logger.error( + 'Check the documentation for correct setup: https://projecterror.dev/docs/pefcl/developers/framework_integration', + ); + logger.error(' '); + } else { + logger.error( + 'Check your starting order. The framework integration library needs to be started before PEFCL!', + ); + } } this.handleResourceStop(); return; } + + logger.info('Successfully verified exports.'); } logger.info(`Starting ${resourceName}.`); @@ -61,5 +74,8 @@ export class BootService { logger.info(`Stopping ${resourceName}.`); emit(GeneralEvents.ResourceStopped); StopResource(resourceName); + StopResource(resourceName); + StopResource(resourceName); + StopResource(resourceName); } } diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts index ef814428..31980544 100644 --- a/src/server/services/card/card.service.ts +++ b/src/server/services/card/card.service.ts @@ -6,7 +6,7 @@ import { Request } from '@typings/http'; import { CardDB } from './card.db'; import { sequelize } from '@server/utils/pool'; import { AccountService } from '../account/account.service'; -import { CardErrors, GenericErrors, UserErrors } from '@server/../../typings/Errors'; +import { BalanceErrors, CardErrors, GenericErrors, UserErrors } from '@server/../../typings/Errors'; import i18next from '@utils/i18n'; import { BlockCardInput, @@ -43,28 +43,39 @@ export class CardService { this.accountService = accountService; } + validateCardsConfig() { + if (!isFrameworkIntegrationEnabled) { + logger.error('Could not give card to player.'); + throw new Error('Phsyical cards are not available without FrameworkIntegration enabled.'); + } + + if (!config.frameworkIntegration?.isCardsEnabled) { + logger.error('Cards are not enabled in the config.'); + throw new Error('Cards are not enabled in the config.'); + } + } + async getCards(req: Request<{ accountId: number }>) { const cards = await this.cardDB.getByAccountId(req.data.accountId); return cards.map((card) => card.toJSON()); } async getInventoryCards(req: Request): Promise { + this.validateCardsConfig(); + const user = this.userService.getUser(req.source); if (!user) { throw new Error(UserErrors.NotFound); } - if (!isFrameworkIntegrationEnabled) { - logger.error('Phsyical cards are not available without FrameworkIntegration enabled.'); - return []; - } - const exports = getFrameworkExports(); return exports.getCards(user.getSource()); } async getAccountByCard(req: Request): Promise { + this.validateCardsConfig(); + logger.silly('Getting account by card.'); const { cardId, pin } = req.data; @@ -97,17 +108,15 @@ export class CardService { } async giveCard(src: number, card: Card) { - if (!isFrameworkIntegrationEnabled) { - logger.error('Could not give card to player.'); - logger.error('Phsyical cards are not available without FrameworkIntegration enabled.'); - return; - } + this.validateCardsConfig(); const exports = getFrameworkExports(); return exports.giveCard(src, card); } async blockCard(req: Request) { + this.validateCardsConfig(); + const { cardId, pin } = req.data; const card = await this.cardDB.getById(cardId); @@ -128,6 +137,7 @@ export class CardService { } async updateCardPin(req: Request): Promise { + this.validateCardsConfig(); logger.silly('Ordering new card for source:' + req.source); const { cardId, newPin, oldPin } = req.data; @@ -154,6 +164,7 @@ export class CardService { } async orderPersonalCard(req: Request): Promise { + this.validateCardsConfig(); logger.debug('Ordering new card for source:' + req.source); const { accountId, paymentAccountId, pin } = req.data; @@ -192,6 +203,10 @@ export class CardService { t, ); + if (paymentAccount.getDataValue('balance') < newCardCost) { + throw new Error(BalanceErrors.InsufficentFunds); + } + this.accountService.removeMoneyByAccountNumber({ ...req, data: { diff --git a/src/server/utils/frameworkIntegration.ts b/src/server/utils/frameworkIntegration.ts index 71930ec4..6135954f 100644 --- a/src/server/utils/frameworkIntegration.ts +++ b/src/server/utils/frameworkIntegration.ts @@ -6,28 +6,32 @@ import { mainLogger } from '@server/sv_logger'; import { getExports } from './misc'; import { config } from './server-config'; -const log = mainLogger.child({ module: 'frameworkIntegration' }); +const logger = mainLogger.child({ module: 'frameworkIntegration' }); const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [ 'addCash', 'removeCash', 'getCash', 'getBank', - 'giveCard', - 'getCards' ]; +if (config?.frameworkIntegration?.isCardsEnabled) { + frameworkIntegrationKeys.push('giveCard'); + frameworkIntegrationKeys.push('getCards'); +} + export const validateResourceExports = (resourceExports: FrameworkIntegrationExports): boolean => { let isValid = true; frameworkIntegrationKeys.forEach((key: FrameworkIntegrationFunction) => { + logger.silly(`Verifying export: ${key}`); if (typeof resourceExports[key] === 'undefined') { - log.error(`Framework integration export ${key} is missing.`); + logger.error(`Framework integration export ${key} is missing.`); isValid = false; return; } if (typeof resourceExports[key] !== 'function') { - log.error(`Framework integration export ${key} is not a function.`); + logger.error(`Framework integration export ${key} is not a function.`); isValid = false; } }); @@ -40,15 +44,15 @@ export const getFrameworkExports = (): FrameworkIntegrationExports => { const resourceName = config?.frameworkIntegration?.resource; const resourceExports: FrameworkIntegrationExports = exps[resourceName ?? '']; - log.debug(`Checking exports from resource: ${resourceName}`); + logger.debug(`Checking exports from resource: ${resourceName}`); if (!resourceName) { - log.error(`Missing resourceName in the config for framework integration`); + logger.error(`Missing resourceName in the config for framework integration`); throw new Error('Framework integration failed'); } if (!resourceExports) { - log.error( + logger.error( `No resource found with name: ${resourceName}. Make sure you have the correct resource name in the config.`, ); throw new Error('Framework integration failed'); diff --git a/typings/config.ts b/typings/config.ts index 4745107c..f147c108 100644 --- a/typings/config.ts +++ b/typings/config.ts @@ -34,6 +34,7 @@ export interface ResourceConfig { enabled: boolean; resource: string; syncInitialBankBalance: boolean; + isCardsEnabled: boolean; }; database: { profileQueries: boolean; diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 6720a027..ec8937c0 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import { Atom } from 'jotai'; import { totalUnpaidInvoicesAtom } from '@data/invoices'; import BadgeAtom from './ui/BadgeAtom'; +import { useConfig } from '@hooks/useConfig'; const List = styled.ul` margin: 0; @@ -97,6 +98,7 @@ const ListItem = ({ to, icon, label, amount, countAtom }: ListItemProps) => { const Sidebar = () => { const { t } = useTranslation(); + const config = useConfig(); return ( @@ -112,7 +114,10 @@ const Sidebar = () => { /> } label={t('Deposit')} /> } label={t('Withdraw')} /> - } label={t('Cards')} /> + + {config.frameworkIntegration.isCardsEnabled && ( + } label={t('Cards')} /> + )} ); }; diff --git a/web/src/views/ATM/ATM.tsx b/web/src/views/ATM/ATM.tsx index ee8c67e3..a8822cf8 100644 --- a/web/src/views/ATM/ATM.tsx +++ b/web/src/views/ATM/ATM.tsx @@ -22,6 +22,8 @@ import BankCard from '@components/BankCard'; import { ErrorRounded } from '@mui/icons-material'; import PinField from '@components/ui/Fields/PinField'; import { useExitListener } from '@hooks/useExitListener'; +import { useAtomValue } from 'jotai'; +import { defaultAccountAtom } from '@data/accounts'; const AnimationContainer = styled.div` position: absolute; @@ -66,18 +68,22 @@ type BankState = 'select-card' | 'enter-pin' | 'withdraw'; const ATM = () => { const { t } = useTranslation(); const config = useConfig(); + const { isCardsEnabled } = config.frameworkIntegration; + const defaultAccount = useAtomValue(defaultAccountAtom); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); const [account, setAccount] = useState(); const [isOpen, setIsOpen] = useState(false); useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsOpen(data)); + const initialStatus: BankState = isCardsEnabled ? 'select-card' : 'withdraw'; + const [selectedCard, setSelectedCard] = useState(); const [cards, setCards] = useState([]); - const [state, setState] = useState('select-card'); + const [state, setState] = useState(initialStatus); const [pin, setPin] = useState(''); - useExitListener(state !== 'enter-pin'); + useExitListener(state === 'withdraw' || state === initialStatus); const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions; @@ -85,7 +91,7 @@ const ATM = () => { setError(''); setPin(''); setAccount(undefined); - setState('select-card'); + setState(initialStatus); }; const handleBack = () => { @@ -123,8 +129,8 @@ const ATM = () => { } }; - updateCards(); - }, [t]); + isCardsEnabled && updateCards(); + }, [t, isCardsEnabled]); const input = { cardId: selectedCard?.id ?? 0, @@ -148,20 +154,31 @@ const ATM = () => { }; const handleWithdraw = async (amount: number) => { - if (!account) { + const withdrawAccount = isCardsEnabled ? account : defaultAccount; + if (!withdrawAccount) { return; } - const payload: ATMInput = { - amount, - cardId: selectedCard?.id, - cardPin: parseInt(pin, 10), - accountId: account?.id, - message: t('Withdrew {{amount}} from an ATM with card {{cardNumber}}.', { - amount, - cardNumber: selectedCard?.number ?? 'unknown', - }), - }; + const accountId = withdrawAccount.id; + + const payload: ATMInput = isCardsEnabled + ? { + amount, + cardId: selectedCard?.id, + cardPin: parseInt(pin, 10), + accountId, + message: t('Withdrew {{amount}} from an ATM with card {{cardNumber}}.', { + amount, + cardNumber: selectedCard?.number ?? 'unknown', + }), + } + : { + amount, + accountId, + message: t('Withdrew {{amount}} from an ATM.', { + amount, + }), + }; setIsLoading(true); @@ -193,6 +210,10 @@ const ATM = () => { const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + if (!isCardsEnabled) { + return; + } + if (pin.length === PIN_CODE_LENGTH && selectedCard?.id) { try { setError(''); @@ -234,6 +255,7 @@ const ATM = () => { setState('enter-pin'); }; + const accountBalance = isCardsEnabled ? account?.balance ?? 0 : defaultAccount?.balance ?? 0; return ( <> @@ -324,7 +346,7 @@ const ATM = () => {
{t('Account balance')} - {formatMoney(account?.balance ?? 0, config.general)} + {formatMoney(accountBalance, config.general)}
{t('Quick withdraw')} @@ -334,7 +356,7 @@ const ATM = () => { key={value} onClick={() => handleWithdraw(value)} data-value={value} - disabled={value > (account?.balance ?? 0) || isLoading} + disabled={value > accountBalance || isLoading} > {formatMoney(value, config.general)} diff --git a/web/src/views/Withdraw/Withdraw.tsx b/web/src/views/Withdraw/Withdraw.tsx index 3edd9498..eb33585b 100644 --- a/web/src/views/Withdraw/Withdraw.tsx +++ b/web/src/views/Withdraw/Withdraw.tsx @@ -42,6 +42,10 @@ const Withdraw = () => { }, [success]); const handleWithdrawal = () => { + if (!selectedAccountId) { + return; + } + const payload: ATMInput = { amount: value, message: t('Withdrew {{amount}} from account.', { amount: formatMoney(value, general) }), From 880790d8983632b382744294e1ef0c7ce4197f41 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Tue, 27 Sep 2022 21:14:30 +0200 Subject: [PATCH 13/17] fix: optional accountId on withdrawal --- src/server/services/account/account.controller.ts | 1 - src/server/services/account/account.service.ts | 4 +++- typings/Account.ts | 2 +- typings/config.ts | 1 - web/src/data/cards.ts | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/server/services/account/account.controller.ts b/src/server/services/account/account.controller.ts index 4c1d5d11..201ddd4c 100644 --- a/src/server/services/account/account.controller.ts +++ b/src/server/services/account/account.controller.ts @@ -15,7 +15,6 @@ import { AddToUniqueAccountInput, RemoveFromUniqueAccountInput, UpdateBankBalanceByNumberInput, - GetATMAccountInput, } from '@typings/Account'; import { AccountEvents, diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index c3db9552..033d4926 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -494,7 +494,9 @@ export class AccountService { } } - const targetAccount = await this._accountDB.getAccountById(accountId); + const targetAccount = accountId + ? await this._accountDB.getAccountById(accountId) + : await this.getDefaultAccountBySource(req.source); if (!targetAccount) { throw new ServerError(GenericErrors.NotFound); diff --git a/typings/Account.ts b/typings/Account.ts index 049925ee..8b48bbeb 100644 --- a/typings/Account.ts +++ b/typings/Account.ts @@ -106,7 +106,7 @@ export type TransactionAccount = Pick; export interface ATMInput { amount: number; message: string; - accountId: number; + accountId?: number; cardId?: number; cardPin?: number; } diff --git a/typings/config.ts b/typings/config.ts index f147c108..6b1d87ad 100644 --- a/typings/config.ts +++ b/typings/config.ts @@ -51,7 +51,6 @@ export interface ResourceConfig { }; cards: { cost: number; - pinLength: number; maxCardsPerAccount: number; }; cash: { diff --git a/web/src/data/cards.ts b/web/src/data/cards.ts index 3497e026..d8abd71b 100644 --- a/web/src/data/cards.ts +++ b/web/src/data/cards.ts @@ -14,6 +14,7 @@ const mockedCards: Card[] = [ number: '4242 4220 1234 9000', holder: 'Charles Carlsberg', pin: 1234, + holderCitizenId: '1', }, { id: 2, @@ -22,6 +23,7 @@ const mockedCards: Card[] = [ number: '4242 4220 1234 9002', holder: 'Charles Carlsberg', pin: 1234, + holderCitizenId: '2', }, { id: 3, @@ -30,6 +32,7 @@ const mockedCards: Card[] = [ number: '4242 4220 1234 9003', holder: 'Charles Carlsberg', pin: 1234, + holderCitizenId: '3', }, ]; From 2241e5fc5e4133f1c22869bbb09e929dedc366af Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Tue, 27 Sep 2022 21:17:15 +0200 Subject: [PATCH 14/17] fix: missing translations for client deposit & withdraw exports --- src/client/cl_api.ts | 9 +++++++-- src/client/cl_integrations.ts | 2 -- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/client/cl_api.ts b/src/client/cl_api.ts index cc59d204..13928acc 100644 --- a/src/client/cl_api.ts +++ b/src/client/cl_api.ts @@ -3,6 +3,7 @@ import { ATMInput } from '@typings/Account'; import { AccountEvents, CashEvents, InvoiceEvents } from '@typings/Events'; import { ServerPromiseResp } from '@typings/http'; import { Invoice, InvoiceOnlineInput } from '@typings/Invoice'; +import { translations } from 'i18n'; export class Api { utils: ClientUtils; @@ -83,7 +84,9 @@ export class Api { try { const payload: ATMInput = { amount, - message: 'Deposition', + message: translations.t('Successfully deposited {{amount}} into selected account.', { + amount, + }), }; const response = await this.utils.emitNetPromise(AccountEvents.DepositMoney, payload); console.log({ response }); @@ -97,7 +100,9 @@ export class Api { try { const payload: ATMInput = { amount, - message: 'Withdrawal', + message: translations.t('Withdrew {{amount}} from an ATM.', { + amount, + }), }; const response = await this.utils.emitNetPromise(AccountEvents.WithdrawMoney, payload); diff --git a/src/client/cl_integrations.ts b/src/client/cl_integrations.ts index 74fede07..7340659f 100644 --- a/src/client/cl_integrations.ts +++ b/src/client/cl_integrations.ts @@ -1,5 +1,3 @@ -import { Card } from '@typings/BankCard'; -import { NUIEvents } from '@typings/Events'; import { setBankIsOpen, setAtmIsOpen } from 'client'; import cl_config from 'cl_config'; import { translations } from 'i18n'; From d79f1ae58b3bd0e4c718e3b1ea47f27e782171fe Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Tue, 4 Oct 2022 20:23:10 +0200 Subject: [PATCH 15/17] fix: fetch cards on atm, default to default acc on withdraw from ATM / Bank --- src/server/services/account/account.service.ts | 4 ++-- web/src/views/ATM/ATM.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts index 033d4926..f19a07f4 100644 --- a/src/server/services/account/account.service.ts +++ b/src/server/services/account/account.service.ts @@ -479,10 +479,10 @@ export class AccountService { const t = await sequelize.transaction(); try { /* If framework is enabled, do a card check, otherwise continue. */ - if (isFrameworkIntegrationEnabled && isCardsEnabled) { + if (isFrameworkIntegrationEnabled && isCardsEnabled && cardId) { const exports = getFrameworkExports(); const cards = exports.getCards(req.source); - const selectedCard = cards.find((card) => card.id === cardId); + const selectedCard = cards?.find((card) => card.id === cardId); if (!selectedCard) { throw new Error('User does not have selected card in inventory.'); diff --git a/web/src/views/ATM/ATM.tsx b/web/src/views/ATM/ATM.tsx index a8822cf8..3c94a800 100644 --- a/web/src/views/ATM/ATM.tsx +++ b/web/src/views/ATM/ATM.tsx @@ -129,8 +129,8 @@ const ATM = () => { } }; - isCardsEnabled && updateCards(); - }, [t, isCardsEnabled]); + isCardsEnabled && isOpen && updateCards(); + }, [t, isCardsEnabled, isOpen]); const input = { cardId: selectedCard?.id ?? 0, From aa1b9dc28924fc9d3aa2d99f30a4f1e9d31dd4d0 Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Tue, 4 Oct 2022 20:27:37 +0200 Subject: [PATCH 16/17] fix: fixed sync issues, reset rawAccountsAtom --- web/src/hooks/useBroadcasts.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/hooks/useBroadcasts.ts b/web/src/hooks/useBroadcasts.ts index f77e3238..3cbf828c 100644 --- a/web/src/hooks/useBroadcasts.ts +++ b/web/src/hooks/useBroadcasts.ts @@ -1,4 +1,4 @@ -import { accountsAtom } from '@data/accounts'; +import { accountsAtom, rawAccountAtom } from '@data/accounts'; import { invoicesAtom } from '@data/invoices'; import { transactionBaseAtom } from '@data/transactions'; import { Account } from '@typings/Account'; @@ -10,6 +10,7 @@ import { useNuiEvent } from '@hooks/useNuiEvent'; export const useBroadcasts = () => { const updateInvoices = useSetAtom(invoicesAtom); const updateTransactions = useSetAtom(transactionBaseAtom); + const setRawAccounts = useSetAtom(rawAccountAtom); const [accounts, updateAccounts] = useAtom(accountsAtom); useNuiEvent('PEFCL', Broadcasts.NewTransaction, () => { @@ -17,7 +18,7 @@ export const useBroadcasts = () => { }); useNuiEvent('PEFCL', Broadcasts.NewAccount, (account: Account) => { - updateAccounts([...accounts, account]); + setRawAccounts([...accounts, account]) }); useNuiEvent('PEFCL', Broadcasts.UpdatedAccount, () => { @@ -25,7 +26,7 @@ export const useBroadcasts = () => { }); useNuiEvent('PEFCL', Broadcasts.NewAccountBalance, (account: Account) => { - updateAccounts(updateAccount(accounts, account)); + setRawAccounts(updateAccount(accounts, account)); }); useNuiEvent('PEFCL', Broadcasts.NewInvoice, () => { From 7156ac649c1a4c593225570085300171e3861f0d Mon Sep 17 00:00:00 2001 From: Anton Stjernquist Date: Wed, 5 Oct 2022 20:23:41 +0200 Subject: [PATCH 17/17] feat: delete cards, disable shared cards, no need for pin to update, delete or block --- src/client/cl_events.ts | 1 + src/server/server.ts | 1 + src/server/services/card/card.controller.ts | 11 +++ src/server/services/card/card.db.ts | 4 +- src/server/services/card/card.service.ts | 88 +++++++++++++---- typings/BankCard.ts | 5 +- typings/Events.ts | 1 + web/src/components/AccountCard.tsx | 19 +++- web/src/views/Cards/CardsView.tsx | 14 ++- web/src/views/Cards/components/BankCards.tsx | 6 ++ .../views/Cards/components/CardActions.tsx | 98 ++++++++++++++----- 11 files changed, 194 insertions(+), 54 deletions(-) diff --git a/src/client/cl_events.ts b/src/client/cl_events.ts index 389c59cb..1ee0dcd1 100644 --- a/src/client/cl_events.ts +++ b/src/client/cl_events.ts @@ -136,6 +136,7 @@ RegisterNuiProxy(CashEvents.GetMyCash); // Cards RegisterNuiProxy(CardEvents.Get); RegisterNuiProxy(CardEvents.Block); +RegisterNuiProxy(CardEvents.Delete); RegisterNuiProxy(CardEvents.OrderPersonal); RegisterNuiProxy(CardEvents.OrderShared); RegisterNuiProxy(CardEvents.UpdatePin); diff --git a/src/server/server.ts b/src/server/server.ts index b0e94b81..1b38b5b5 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -104,6 +104,7 @@ if (isMocking) { app.post(...createEndpoint(CardEvents.OrderPersonal)); app.post(...createEndpoint(CardEvents.UpdatePin)); app.post(...createEndpoint(CardEvents.Block)); + app.post(...createEndpoint(CardEvents.Delete)); app.post(...createEndpoint(CardEvents.GetInventoryCards)); app.listen(port, async () => { diff --git a/src/server/services/card/card.controller.ts b/src/server/services/card/card.controller.ts index cd5af566..ed13a187 100644 --- a/src/server/services/card/card.controller.ts +++ b/src/server/services/card/card.controller.ts @@ -4,6 +4,7 @@ import { BlockCardInput, Card, CreateCardInput, + DeleteCardInput, GetCardInput, InventoryCard, UpdateCardPinInput, @@ -53,6 +54,16 @@ export class CardController { } } + @NetPromise(CardEvents.Delete) + async deleteCard(req: Request, res: Response) { + try { + const isDeleted = await this.cardService.blockCard(req); + res({ status: 'ok', data: isDeleted }); + } catch (error) { + res({ status: 'error', errorMsg: error.message }); + } + } + @NetPromise(CardEvents.UpdatePin) async updatePin(req: Request, res: Response) { try { diff --git a/src/server/services/card/card.db.ts b/src/server/services/card/card.db.ts index f3f9e872..a0767df2 100644 --- a/src/server/services/card/card.db.ts +++ b/src/server/services/card/card.db.ts @@ -8,8 +8,8 @@ export class CardDB { return await CardModel.findAll(); } - async getById(cardId: number): Promise { - return await CardModel.findOne({ where: { id: cardId } }); + async getById(cardId: number, transaction?: Transaction): Promise { + return await CardModel.findOne({ where: { id: cardId }, transaction }); } async getByAccountId(accountId: number): Promise { diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts index 31980544..3284ffec 100644 --- a/src/server/services/card/card.service.ts +++ b/src/server/services/card/card.service.ts @@ -6,7 +6,13 @@ import { Request } from '@typings/http'; import { CardDB } from './card.db'; import { sequelize } from '@server/utils/pool'; import { AccountService } from '../account/account.service'; -import { BalanceErrors, CardErrors, GenericErrors, UserErrors } from '@server/../../typings/Errors'; +import { + AuthorizationErrors, + BalanceErrors, + CardErrors, + GenericErrors, + UserErrors, +} from '@server/../../typings/Errors'; import i18next from '@utils/i18n'; import { BlockCardInput, @@ -116,45 +122,88 @@ export class CardService { async blockCard(req: Request) { this.validateCardsConfig(); + logger.silly('Blocking card ..'); + logger.silly(req.data); - const { cardId, pin } = req.data; - const card = await this.cardDB.getById(cardId); + const user = this.userService.getUser(req.source); + const { cardId } = req.data; - if (!card) { - throw new Error(GenericErrors.NotFound); + const t = await sequelize.transaction(); + const card = await this.cardDB.getById(cardId, t); + + if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) { + throw new Error(AuthorizationErrors.Forbidden); } - if (pin !== card.getDataValue('pin')) { - throw new Error(CardErrors.InvalidPin); + if (!card) { + throw new Error(GenericErrors.NotFound); } try { await card.update({ isBlocked: true }); + t.commit(); + logger.silly('Blocked card.'); return true; - } catch (error) { + } catch (error: unknown) { + t.rollback(); + logger.error(error); return false; } } - async updateCardPin(req: Request): Promise { + async deleteCard(req: Request) { this.validateCardsConfig(); - logger.silly('Ordering new card for source:' + req.source); + logger.silly('Deleting card ..'); + logger.silly(req.data); + const user = this.userService.getUser(req.source); + const { cardId } = req.data; - const { cardId, newPin, oldPin } = req.data; - const card = await this.cardDB.getById(cardId); + const t = await sequelize.transaction(); + const card = await this.cardDB.getById(cardId, t); + + if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) { + throw new Error(AuthorizationErrors.Forbidden); + } if (!card) { throw new Error(GenericErrors.NotFound); } - if (card.getDataValue('pin') !== oldPin) { - throw new Error(CardErrors.InvalidPin); + try { + await card.destroy(); + t.commit(); + logger.silly('Deleted card.'); + return true; + } catch (error: unknown) { + t.rollback(); + logger.error(error); + return false; } + } + + async updateCardPin(req: Request): Promise { + this.validateCardsConfig(); + logger.silly('Updating pin for card ..'); + logger.silly(req.data); + + const user = this.userService.getUser(req.source); + const { cardId, newPin } = req.data; const t = await sequelize.transaction(); + const card = await this.cardDB.getById(cardId, t); + + if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) { + throw new Error(AuthorizationErrors.Forbidden); + } + + if (!card) { + throw new Error(GenericErrors.NotFound); + } + try { await card.update({ pin: newPin }, { transaction: t }); t.commit(); + logger.silly('Updated pin.'); return true; } catch (error) { logger.error(error); @@ -165,10 +214,12 @@ export class CardService { async orderPersonalCard(req: Request): Promise { this.validateCardsConfig(); - logger.debug('Ordering new card for source:' + req.source); - const { accountId, paymentAccountId, pin } = req.data; + logger.silly('Ordering new card ..'); + logger.silly(req.data); const user = this.userService.getUser(req.source); + const { accountId, paymentAccountId, pin } = req.data; + const newCardCost = config.cards?.cost; if (!newCardCost) { @@ -224,9 +275,10 @@ export class CardService { this.giveCard(req.source, card.toJSON()); t.commit(); + logger.silly('Ordered new card.'); return card.toJSON(); - } catch (err) { - logger.error(err); + } catch (error: unknown) { + logger.error(error); t.rollback(); throw new Error(i18next.t('Failed to create new account')); } diff --git a/typings/BankCard.ts b/typings/BankCard.ts index ca2faf29..f6c32de4 100644 --- a/typings/BankCard.ts +++ b/typings/BankCard.ts @@ -29,13 +29,14 @@ export interface CreateCardInput { accountId: number; paymentAccountId: number; } + export interface BlockCardInput { cardId: number; - pin: number; } +export type DeleteCardInput = BlockCardInput; + export interface UpdateCardPinInput { cardId: number; newPin: number; - oldPin: number; } diff --git a/typings/Events.ts b/typings/Events.ts index 8a187659..52dbb9ea 100644 --- a/typings/Events.ts +++ b/typings/Events.ts @@ -88,6 +88,7 @@ export enum CardEvents { OrderShared = 'pefcl:orderSharedCard', OrderPersonal = 'pefcl:orderPersonalCard', Block = 'pefcl:blockCard', + Delete = 'pefcl:deleteCard', UpdatePin = 'pefcl:updatePin', NewCard = 'pefcl:newCard', GetInventoryCards = 'pefcl:getInventoryCards', diff --git a/web/src/components/AccountCard.tsx b/web/src/components/AccountCard.tsx index 471f9e3c..18f95c0c 100644 --- a/web/src/components/AccountCard.tsx +++ b/web/src/components/AccountCard.tsx @@ -12,7 +12,12 @@ import { IconButton, Skeleton, Stack } from '@mui/material'; import { ContentCopyRounded } from '@mui/icons-material'; import copy from 'copy-to-clipboard'; -const Container = styled.div<{ accountType: AccountType; selected: boolean }>` +interface ContainerProps { + isDisabled: boolean; + accountType: AccountType; + selected: boolean; +} +const Container = styled.div` user-select: none; width: 100%; padding: 1rem; @@ -42,6 +47,12 @@ const Container = styled.div<{ accountType: AccountType; selected: boolean }>` ` border: 2px solid ${theme.palette.primary.light}; `}; + + ${({ isDisabled }) => + isDisabled && + ` + opacity: 0.5; + `} `; const Row = styled.div` @@ -78,12 +89,14 @@ type AccountCardProps = { account: Account; selected?: boolean; withCopy?: boolean; + isDisabled?: boolean; }; export const AccountCard = ({ account, selected = false, withCopy = false, + isDisabled = false, ...props }: AccountCardProps) => { const { type, id, balance, isDefault, accountName, number } = account; @@ -91,7 +104,7 @@ export const AccountCard = ({ const config = useConfig(); return ( - + {formatMoney(balance, config.general)} @@ -126,7 +139,7 @@ export const AccountCard = ({ export const LoadingAccountCard = () => { return ( - + diff --git a/web/src/views/Cards/CardsView.tsx b/web/src/views/Cards/CardsView.tsx index fad42b19..b1959848 100644 --- a/web/src/views/Cards/CardsView.tsx +++ b/web/src/views/Cards/CardsView.tsx @@ -12,6 +12,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import theme from '@utils/theme'; import BankCards from './components/BankCards'; import { selectedAccountIdAtom } from '@data/cards'; +import { AccountType } from '@typings/Account'; const Container = styled.div` overflow: auto; @@ -58,8 +59,17 @@ const CardsView = () => { {accounts.map((account) => ( -
setSelectedAccountId(account.id)}> - +
+ account.type !== AccountType.Shared && setSelectedAccountId(account.id) + } + > +
))} diff --git a/web/src/views/Cards/components/BankCards.tsx b/web/src/views/Cards/components/BankCards.tsx index a607a5ae..97351f57 100644 --- a/web/src/views/Cards/components/BankCards.tsx +++ b/web/src/views/Cards/components/BankCards.tsx @@ -136,6 +136,7 @@ const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps }; const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId); + const selectedCard = cards.find((card) => card.id === selectedCardId); const isAffordable = (selectedAccount?.balance ?? 0) > cost; return ( @@ -180,11 +181,16 @@ const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps {Boolean(selectedCardId) && ( { updateCards(accountId); onSelectCardId(0); }} + onDelete={() => { + updateCards(accountId); + onSelectCardId(0); + }} /> )} diff --git a/web/src/views/Cards/components/CardActions.tsx b/web/src/views/Cards/components/CardActions.tsx index 798af02d..4ac9904b 100644 --- a/web/src/views/Cards/components/CardActions.tsx +++ b/web/src/views/Cards/components/CardActions.tsx @@ -7,7 +7,6 @@ import { DialogContentText, DialogTitle, Stack, - Typography, } from '@mui/material'; import { Heading1 } from '@components/ui/Typography/Headings'; import { PreHeading } from '@components/ui/Typography/BodyText'; @@ -16,33 +15,30 @@ import BaseDialog from '@components/Modals/BaseDialog'; import { fetchNui } from '@utils/fetchNui'; import { CardEvents } from '@typings/Events'; import { CheckRounded, ErrorRounded, InfoRounded } from '@mui/icons-material'; -import { BlockCardInput, UpdateCardPinInput } from '@typings/BankCard'; +import { BlockCardInput, DeleteCardInput, UpdateCardPinInput } from '@typings/BankCard'; import PinField from '@components/ui/Fields/PinField'; interface CardActionsProps { cardId: number; + isBlocked?: boolean; onBlock?(): void; + onDelete?(): void; } -const CardActions = ({ cardId, onBlock }: CardActionsProps) => { +const CardActions = ({ cardId, onBlock, onDelete, isBlocked }: CardActionsProps) => { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [isUpdatingPin, setIsUpdatingPin] = useState(false); - const [isBlockingCard, setIsBlockingCard] = useState(false); - const [pin, setPin] = useState(''); - const [oldPin, setOldPin] = useState(''); + const [dialog, setDialog] = useState<'none' | 'block' | 'update' | 'delete'>('none'); const [newPin, setNewPin] = useState(''); const [confirmNewPin, setConfirmNewPin] = useState(''); const { t } = useTranslation(); const handleClose = () => { setIsLoading(false); - setIsUpdatingPin(false); - setIsBlockingCard(false); + setDialog('none'); setError(''); setNewPin(''); - setOldPin(''); setConfirmNewPin(''); setTimeout(() => { @@ -58,7 +54,6 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { await fetchNui(CardEvents.Block, { cardId, - pin: parseInt(pin, 10), }); setSuccess(t('Successfully blocked the card.')); handleClose(); @@ -72,6 +67,27 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { setIsLoading(false); }; + const handleDeleteCard = async () => { + try { + setSuccess(''); + setError(''); + setIsLoading(true); + + await fetchNui(CardEvents.Delete, { + cardId, + }); + setSuccess(t('Successfully deleted the card.')); + handleClose(); + onDelete?.(); + } catch (err: unknown | Error) { + if (err instanceof Error) { + setError(err.message); + } + } + + setIsLoading(false); + }; + const handleUpdatePin = async () => { try { setError(''); @@ -83,7 +99,7 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { } setIsLoading(true); - const data = { cardId, newPin: parseInt(newPin, 10), oldPin: parseInt(oldPin, 10) }; + const data = { cardId, newPin: parseInt(newPin, 10) }; await fetchNui(CardEvents.UpdatePin, data); setSuccess(t('Successfully updated pin.')); @@ -107,10 +123,18 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { - - + + + {success && ( @@ -121,16 +145,11 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { - + {t('Update pin')} - setOldPin(event.target.value)} - /> { - + {t('Blocking card')} @@ -172,12 +191,6 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { {t('Are you sure you want to block this card? This action cannot be undone.')} - {t('Enter card pin to block the card.')} - setPin(event.target.value)} - /> {error && ( @@ -200,6 +213,37 @@ const CardActions = ({ cardId, onBlock }: CardActionsProps) => { + + + {t('Deleting card')} + + + + + {t('Are you sure you want to delete this card? This action cannot be undone.')} + + + + {error && ( + : } + color={isLoading ? 'info' : 'error'} + > + {error} + + )} + + + + + + + + ); };