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} + + )} + + + + + + + + ); };