From 3ae754853b9a2c6f4ba4f7ad3f7aef83475b6727 Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Wed, 8 Apr 2026 18:21:49 +0000 Subject: [PATCH 1/8] Add itemized receipt changelog support, update manual approval threshold format, fix bug - Add UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT action type to CONST - Add original message fields for itemized receipt amounts - Add getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage function - Add dispatch in PureReportActionItem and SidebarUtils - Add English and Spanish translations for itemized receipt changelogs - Fix bug in getUpdatedManualApprovalThresholdMessage that checked oldLimit twice instead of checking newLimit on the second clause Co-authored-by: Daniel Gale-Rosen --- src/CONST/index.ts | 1 + src/languages/en.ts | 3 +++ src/languages/es.ts | 3 +++ src/libs/ReportActionsUtils.ts | 27 ++++++++++++++++++- src/libs/SidebarUtils.ts | 3 +++ .../inbox/report/PureReportActionItem.tsx | 3 +++ src/types/onyx/OriginalMessage.ts | 6 +++++ 7 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e57a1b1f9cf8..77d44ad0b2be 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1558,6 +1558,7 @@ const CONST = { UPDATE_MANUAL_APPROVAL_THRESHOLD: 'POLICYCHANGELOG_UPDATE_MANUAL_APPROVAL_THRESHOLD', UPDATE_MAX_EXPENSE_AMOUNT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT', UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT', + UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT', UPDATE_MAX_EXPENSE_AGE: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AGE', UPDATE_MULTIPLE_TAGS_APPROVER_RULES: 'POLICYCHANGELOG_UPDATE_MULTIPLE_TAGS_APPROVER_RULES', UPDATE_NAME: 'POLICYCHANGELOG_UPDATE_NAME', diff --git a/src/languages/en.ts b/src/languages/en.ts index 17dbf0ea29ee..dd5d4560c9ed 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7378,6 +7378,9 @@ const translations = { setReceiptRequiredAmount: (newValue: string) => `set receipt required amount to "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `changed receipt required amount to "${newValue}" (previously "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `removed receipt required amount (previously "${oldValue}")`, + setItemizedReceiptRequiredAmount: (newValue: string) => `set itemized receipt required amount to "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `changed itemized receipt required amount to "${newValue}" (previously "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `removed itemized receipt required amount (previously "${oldValue}")`, setMaxExpenseAmount: (newValue: string) => `set max expense amount to "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `changed max expense amount to "${newValue}" (previously "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `removed max expense amount (previously "${oldValue}")`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 6a30c8c202d8..6d0fdedf6089 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6943,6 +6943,9 @@ ${amount} para ${merchant} - ${date}`, setReceiptRequiredAmount: (newValue) => `estableció el importe requerido del recibo en "${newValue}"`, changedReceiptRequiredAmount: (oldValue, newValue) => `cambió el importe requerido del recibo a "${newValue}" (antes "${oldValue}")`, removedReceiptRequiredAmount: (oldValue) => `eliminó el importe requerido del recibo (antes "${oldValue}")`, + setItemizedReceiptRequiredAmount: (newValue) => `estableció el importe requerido del recibo detallado en "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue, newValue) => `cambió el importe requerido del recibo detallado a "${newValue}" (antes "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue) => `eliminó el importe requerido del recibo detallado (antes "${oldValue}")`, setMaxExpenseAmount: (newValue) => `estableció el importe máximo del gasto en "${newValue}"`, changedMaxExpenseAmount: (oldValue, newValue) => `cambió el importe máximo del gasto a "${newValue}" (antes "${oldValue}")`, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1c31711ce7a7..a8299c4f6ef9 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -3612,6 +3612,30 @@ function getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate: Localized return getReportActionText(action); } +function getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage(translate: LocalizedTranslate, action: ReportAction): string { + const {oldMaxExpenseAmountNoItemizedReceipt, newMaxExpenseAmountNoItemizedReceipt, currency} = + getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof oldMaxExpenseAmountNoItemizedReceipt === 'number' && typeof newMaxExpenseAmountNoItemizedReceipt === 'number') { + const oldIsDisabled = oldMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const newIsDisabled = newMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const oldValue = oldIsDisabled ? '' : convertToDisplayString(oldMaxExpenseAmountNoItemizedReceipt, currency); + const newValue = newIsDisabled ? '' : convertToDisplayString(newMaxExpenseAmountNoItemizedReceipt, currency); + + if (oldIsDisabled && !newIsDisabled) { + return translate('workspaceActions.setItemizedReceiptRequiredAmount', newValue); + } + + if (!oldIsDisabled && newIsDisabled) { + return translate('workspaceActions.removedItemizedReceiptRequiredAmount', oldValue); + } + + return translate('workspaceActions.changedItemizedReceiptRequiredAmount', oldValue, newValue); + } + + return getReportActionText(action); +} + function getPolicyChangeLogMaxExpenseAmountMessage(translate: LocalizedTranslate, action: ReportAction): string { const {oldMaxExpenseAmount, newMaxExpenseAmount, currency} = getOriginalMessage(action as ReportAction) ?? {}; @@ -3927,7 +3951,7 @@ function getUpdatedManualApprovalThresholdMessage(translate: LocalizedTranslate, currency = CONST.CURRENCY.USD, } = getOriginalMessage(reportAction as ReportAction) ?? {}; - if (typeof oldLimit !== 'number' || typeof oldLimit !== 'number') { + if (typeof oldLimit !== 'number' || typeof newLimit !== 'number') { return getReportActionText(reportAction); } return translate('workspaceActions.updatedManualApprovalThreshold', {oldLimit: convertToDisplayString(oldLimit, currency), newLimit: convertToDisplayString(newLimit, currency)}); @@ -4684,6 +4708,7 @@ export { getForeignCurrencyDefaultTaxUpdateMessage, getWorkspaceFrequencyUpdateMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountMessage, getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogDefaultBillableMessage, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index fe3de00aec9a..2f24c4e9e5ac 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -73,6 +73,7 @@ import { getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogMaxExpenseAmountMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogUpdateEmployee, getReimburserUpdateMessage, getRemovedCardFeedMessage, @@ -1036,6 +1037,8 @@ function getOptionData({ result.alternateText = getCompanyAddressUpdateMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT) { result.alternateText = getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT) { + result.alternateText = getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT) { result.alternateText = getPolicyChangeLogMaxExpenseAmountMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE) { diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index ca19344d56b7..32597980f4c8 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -119,6 +119,7 @@ import { getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogMaxExpenseAmountMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogUpdateEmployee, getReimburserUpdateMessage, getRemovedCardFeedMessage, @@ -1626,6 +1627,8 @@ function PureReportActionItem({ children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT)) { children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT)) { + children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE)) { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 41b63bc21800..7fc49006c20b 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -475,6 +475,12 @@ type OriginalMessagePolicyChangeLog = { /** New value for max expense amount with no receipt */ newMaxExpenseAmountNoReceipt?: number; + /** Old value for max expense amount with no itemized receipt */ + oldMaxExpenseAmountNoItemizedReceipt?: number; + + /** New value for max expense amount with no itemized receipt */ + newMaxExpenseAmountNoItemizedReceipt?: number; + /** Currency of the policy */ currency?: string; From 1edd819c3976f9242d206a278fde99614895b818 Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Wed, 8 Apr 2026 18:27:12 +0000 Subject: [PATCH 2/8] Fix prettier formatting for import order Co-authored-by: Daniel Gale-Rosen --- src/libs/SidebarUtils.ts | 2 +- src/pages/inbox/report/PureReportActionItem.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2f24c4e9e5ac..85362be9e161 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -72,8 +72,8 @@ import { getPolicyChangeLogEmployeeLeftMessage, getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogMaxExpenseAmountMessage, - getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, + getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogUpdateEmployee, getReimburserUpdateMessage, getRemovedCardFeedMessage, diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 32597980f4c8..153ec506951e 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -118,8 +118,8 @@ import { getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogMaxExpenseAmountMessage, - getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, + getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogUpdateEmployee, getReimburserUpdateMessage, getRemovedCardFeedMessage, From 0af89cf28dc9d53be8b0e9b59effd1d209ae37af Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Wed, 8 Apr 2026 18:34:39 +0000 Subject: [PATCH 3/8] Add missing itemized receipt translations for all languages Co-authored-by: Daniel Gale-Rosen --- src/languages/de.ts | 5 ++++- src/languages/fr.ts | 3 +++ src/languages/it.ts | 4 ++++ src/languages/ja.ts | 3 +++ src/languages/nl.ts | 4 ++++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 3 +++ src/languages/zh-hans.ts | 5 ++++- 8 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index e650ed7ef72c..c540c5c58f1d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7272,7 +7272,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, setReceiptRequiredAmount: (newValue: string) => `Belegpflichtigen Betrag auf „${newValue}“ festlegen`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `Belegpflichtbetrag auf „${newValue}“ geändert (zuvor „${oldValue}“)`, - removedReceiptRequiredAmount: (oldValue: string) => `Belegpflichtigen Betrag entfernt (zuvor „${oldValue}“)`, + removedReceiptRequiredAmount: (oldValue: string) => `Belegpflichtigen Betrag entfernt (zuvor „${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `Pflichtbetrag für aufgeschlüsselten Beleg auf „${newValue}” festgelegt`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `Pflichtbetrag für aufgeschlüsselten Beleg auf „${newValue}” geändert (zuvor „${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `Pflichtbetrag für aufgeschlüsselten Beleg entfernt (zuvor „${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `maximalen Ausgabenbetrag auf „${newValue}“ festlegen`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `hat den maximalen Ausgabenbetrag auf „${newValue}“ geändert (zuvor „${oldValue}“)`, removedMaxExpenseAmount: (oldValue: string) => `maximale Ausgabenhöhe entfernt (zuvor „${oldValue}“)`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2d13b29a885f..20a88d3f97aa 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7295,6 +7295,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setReceiptRequiredAmount: (newValue: string) => `définir le montant de reçu obligatoire sur « ${newValue} »`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `a modifié le montant requis pour le reçu à « ${newValue} » (auparavant « ${oldValue} »)`, removedReceiptRequiredAmount: (oldValue: string) => `a supprimé le montant requis pour le reçu (précédemment « ${oldValue} »)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `a défini le montant requis pour le reçu détaillé sur « ${newValue} »`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `a modifié le montant requis pour le reçu détaillé à « ${newValue} » (auparavant « ${oldValue} »)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `a supprimé le montant requis pour le reçu détaillé (précédemment « ${oldValue} »)`, setMaxExpenseAmount: (newValue: string) => `définir le montant maximal de dépense sur « ${newValue} »`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `montant maximal de dépense modifié en « ${newValue} » (précédemment « ${oldValue} »)`, removedMaxExpenseAmount: (oldValue: string) => `montant de dépense maximal supprimé (précédemment « ${oldValue} »)`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 7cde855b8932..11e36b0a80ba 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7260,6 +7260,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setReceiptRequiredAmount: (newValue: string) => `imposta l’importo richiesto per la ricevuta su "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `ha modificato l’importo richiesto per la ricevuta in "${newValue}" (in precedenza "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `ha rimosso l’importo richiesto per la ricevuta (in precedenza «${oldValue}»)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `ha impostato l’importo richiesto per la ricevuta dettagliata su "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => + `ha modificato l’importo richiesto per la ricevuta dettagliata in "${newValue}" (in precedenza "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `ha rimosso l’importo richiesto per la ricevuta dettagliata (in precedenza "${oldValue}")`, setMaxExpenseAmount: (newValue: string) => `imposta l’importo massimo della spesa su "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `ha modificato l'importo massimo della spesa a "${newValue}" (precedentemente "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `importo massimo spesa rimosso (precedentemente "${oldValue}")`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f0a6dc81d8df..30f78c3697e2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7174,6 +7174,9 @@ ${reportName} setReceiptRequiredAmount: (newValue: string) => `必要な領収書金額を「${newValue}」に設定`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `領収書必須金額を「${newValue}」(以前は「${oldValue}」)に変更しました`, removedReceiptRequiredAmount: (oldValue: string) => `必須領収書金額を削除しました(以前の値:「${oldValue}」)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `明細付き領収書の必須金額を「${newValue}」に設定しました`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `明細付き領収書の必須金額を「${newValue}」に変更しました(以前は「${oldValue}」)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `明細付き領収書の必須金額を削除しました(以前の値:「${oldValue}」)`, setMaxExpenseAmount: (newValue: string) => `最大経費金額を「${newValue}」に設定`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `上限経費額を「${newValue}」に変更しました(以前は「${oldValue}」)`, removedMaxExpenseAmount: (oldValue: string) => `最大経費金額を削除しました(以前の値: 「${oldValue}」)`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ff18cdf3b591..b962e37c91eb 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7239,6 +7239,10 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setReceiptRequiredAmount: (newValue: string) => `stel het vereiste bonbedrag in op "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `heeft het vereiste bonbedrag gewijzigd naar "${newValue}" (voorheen "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `vereiste bedrag op verwijderde bon (voorheen “${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `vereist bedrag voor gespecificeerde bon ingesteld op “${newValue}”`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => + `heeft het vereiste bedrag voor gespecificeerde bon gewijzigd naar “${newValue}” (voorheen “${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `vereist bedrag voor gespecificeerde bon verwijderd (voorheen “${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `maximumbedrag voor uitgave instellen op "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `maximale onkostensom gewijzigd naar "${newValue}" (voorheen "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `maximaal bedrag voor uitgave verwijderd (voorheen "${oldValue}")`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9de7099eee65..3a6f6b250eda 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7228,6 +7228,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setReceiptRequiredAmount: (newValue: string) => `ustaw wymaganą kwotę paragonu na „${newValue}”`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `zmienił(a) wymaganą kwotę paragonu na „${newValue}” (wcześniej „${oldValue}”)`, removedReceiptRequiredAmount: (oldValue: string) => `usunięto wymagany limit paragonu (wcześniej „${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `ustawiono wymaganą kwotę szczegółowego paragonu na „${newValue}”`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `zmieniono wymaganą kwotę szczegółowego paragonu na „${newValue}” (wcześniej „${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `usunięto wymaganą kwotę szczegółowego paragonu (wcześniej „${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `ustaw maksymalną kwotę wydatku na „${newValue}”`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `zmieniono maksymalną kwotę wydatku na „${newValue}” (wcześniej „${oldValue}”)`, removedMaxExpenseAmount: (oldValue: string) => `usunięto maksymalną kwotę wydatku (wcześniej „${oldValue}”)`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fbddaffffeeb..7d107ee5c412 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7231,6 +7231,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setReceiptRequiredAmount: (newValue: string) => `definir valor exigido do recibo como "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `alterou o valor obrigatório do recibo para "${newValue}" (antes "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `removeu o valor obrigatório do recibo (antes era "${oldValue}")`, + setItemizedReceiptRequiredAmount: (newValue: string) => `definiu o valor obrigatório do recibo detalhado como "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `alterou o valor obrigatório do recibo detalhado para "${newValue}" (antes "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `removeu o valor obrigatório do recibo detalhado (antes era "${oldValue}")`, setMaxExpenseAmount: (newValue: string) => `definir valor máximo da despesa como "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `alterou o valor máximo da despesa para "${newValue}" (antes "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `removeu o valor máximo de despesa (anteriormente "${oldValue}")`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3de0ae015fdf..e2a391f481a1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7043,7 +7043,10 @@ ${reportName} }, setReceiptRequiredAmount: (newValue: string) => `将需要收据的金额设置为“${newValue}”`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `已将所需收据金额更改为“${newValue}”(之前为“${oldValue}”)`, - removedReceiptRequiredAmount: (oldValue: string) => `已移除所需收据金额(先前为“${oldValue}”)`, + removedReceiptRequiredAmount: (oldValue: string) => `已移除所需收据金额(先前为”${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `将逐项收据所需金额设置为”${newValue}”`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `已将逐项收据所需金额更改为”${newValue}”(之前为”${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `已移除逐项收据所需金额(先前为”${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `将最高报销金额设置为“${newValue}”`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `将最高报销金额更改为 “${newValue}”(之前为 “${oldValue}”)`, removedMaxExpenseAmount: (oldValue: string) => `已移除最⾼报销金额(原为“${oldValue}”)`, From d5c0815d7beef49cab50c044b1bd42c3b75348cc Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Wed, 8 Apr 2026 20:46:56 +0000 Subject: [PATCH 4/8] Rename oldIsDisabled/newIsDisabled to isOldLimitDisabled/isNewLimitDisabled Co-authored-by: Daniel Gale-Rosen --- src/libs/ReportActionsUtils.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index a8299c4f6ef9..10ea68413baa 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -3617,16 +3617,16 @@ function getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage(translate: L getOriginalMessage(action as ReportAction) ?? {}; if (typeof oldMaxExpenseAmountNoItemizedReceipt === 'number' && typeof newMaxExpenseAmountNoItemizedReceipt === 'number') { - const oldIsDisabled = oldMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; - const newIsDisabled = newMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; - const oldValue = oldIsDisabled ? '' : convertToDisplayString(oldMaxExpenseAmountNoItemizedReceipt, currency); - const newValue = newIsDisabled ? '' : convertToDisplayString(newMaxExpenseAmountNoItemizedReceipt, currency); + const isOldLimitDisabled = oldMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const isNewLimitDisabled = newMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const oldValue = isOldLimitDisabled ? '' : convertToDisplayString(oldMaxExpenseAmountNoItemizedReceipt, currency); + const newValue = isNewLimitDisabled ? '' : convertToDisplayString(newMaxExpenseAmountNoItemizedReceipt, currency); - if (oldIsDisabled && !newIsDisabled) { + if (isOldLimitDisabled && !isNewLimitDisabled) { return translate('workspaceActions.setItemizedReceiptRequiredAmount', newValue); } - if (!oldIsDisabled && newIsDisabled) { + if (!isOldLimitDisabled && isNewLimitDisabled) { return translate('workspaceActions.removedItemizedReceiptRequiredAmount', oldValue); } From b6991d7d056bb3c9b6846e7a4fa27eda3d5286ea Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Wed, 8 Apr 2026 20:56:52 +0000 Subject: [PATCH 5/8] Fix: update AddNewCardPage to match main and remove stale isModalVisible reference The file on main was refactored from useState/isModalVisible to useConfirmModal but a reference to isModalVisible was left on line 85 (SelectCountryStep's disableAutoFocus prop). This caused TS2304 when CI merges main into the branch. Co-authored-by: Daniel Gale-Rosen --- .../Wallet/PersonalCards/AddNewCardPage.tsx | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx b/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx index d9f3d7b39cba..9e139591a2ab 100644 --- a/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx +++ b/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx @@ -1,7 +1,8 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; -import ConfirmModal from '@components/ConfirmModal'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -25,7 +26,7 @@ function AddPersonalNewCardPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const {currentStep} = addNewPersonalCardFeed ?? {}; - const [isModalVisible, setIsModalVisible] = useState(false); + const {showConfirmModal} = useConfirmModal(); const {translate} = useLocalize(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isAddCardFeedLoading = isLoadingOnyxValue(addNewPersonalCardFeedMetadata); @@ -61,31 +62,31 @@ function AddPersonalNewCardPage() { CurrentStep = ; break; case CONST.PERSONAL_CARDS.STEP.PLAID_CONNECTION: - CurrentStep = setIsModalVisible(true)} />; + CurrentStep = ( + { + showConfirmModal({ + title: translate('workspace.companyCards.addNewCard.exitModal.title'), + success: true, + confirmText: translate('workspace.companyCards.addNewCard.exitModal.confirmText'), + cancelText: translate('workspace.companyCards.addNewCard.exitModal.cancelText'), + prompt: translate('workspace.companyCards.addNewCard.exitModal.prompt'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, false, betas); + }); + }} + /> + ); break; default: - CurrentStep = ; + CurrentStep = ; break; } - return ( - <> - {CurrentStep} - setIsModalVisible(false)} - onConfirm={() => { - setIsModalVisible(false); - navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, false, betas); - }} - /> - - ); + return {CurrentStep}; } export default AddPersonalNewCardPage; From 323c767534c785e8b78390e0bd91b4a59481a64e Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Thu, 9 Apr 2026 18:56:15 +0000 Subject: [PATCH 6/8] Merge main into branch (recreated from main due to missing shared history) The original branch had no common ancestor with main (created in isolated worktree), making a standard merge impossible. Recreated by applying PR changes on top of current main and resolving the conflict in AddNewCardPage.tsx (removed stale useState/isModalVisible references that main had already partially cleaned up). Co-authored-by: Daniel Gale-Rosen --- src/CONST/index.ts | 1 + src/languages/de.ts | 5 +++- src/languages/en.ts | 3 +++ src/languages/es.ts | 3 +++ src/languages/fr.ts | 3 +++ src/languages/it.ts | 4 +++ src/languages/ja.ts | 3 +++ src/languages/nl.ts | 4 +++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 3 +++ src/languages/zh-hans.ts | 5 +++- src/libs/ReportActionsUtils.ts | 27 ++++++++++++++++++- src/libs/SidebarUtils.ts | 3 +++ .../inbox/report/PureReportActionItem.tsx | 3 +++ .../Wallet/PersonalCards/AddNewCardPage.tsx | 22 ++++++--------- src/types/onyx/OriginalMessage.ts | 6 +++++ 16 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3d66a4d43fac..724ab8c0a4cb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1625,6 +1625,7 @@ const CONST = { UPDATE_MANUAL_APPROVAL_THRESHOLD: 'POLICYCHANGELOG_UPDATE_MANUAL_APPROVAL_THRESHOLD', UPDATE_MAX_EXPENSE_AMOUNT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT', UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT', + UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT', UPDATE_MAX_EXPENSE_AGE: 'POLICYCHANGELOG_UPDATE_MAX_EXPENSE_AGE', UPDATE_MULTIPLE_TAGS_APPROVER_RULES: 'POLICYCHANGELOG_UPDATE_MULTIPLE_TAGS_APPROVER_RULES', UPDATE_NAME: 'POLICYCHANGELOG_UPDATE_NAME', diff --git a/src/languages/de.ts b/src/languages/de.ts index 963c61ac7a7c..40783f846a81 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7277,7 +7277,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, setReceiptRequiredAmount: (newValue: string) => `Belegpflichtigen Betrag auf „${newValue}“ festlegen`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `Belegpflichtbetrag auf „${newValue}“ geändert (zuvor „${oldValue}“)`, - removedReceiptRequiredAmount: (oldValue: string) => `Belegpflichtigen Betrag entfernt (zuvor „${oldValue}“)`, + removedReceiptRequiredAmount: (oldValue: string) => `Belegpflichtigen Betrag entfernt (zuvor „${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `Pflichtbetrag für aufgeschlüsselten Beleg auf „${newValue}” festgelegt`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `Pflichtbetrag für aufgeschlüsselten Beleg auf „${newValue}” geändert (zuvor „${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `Pflichtbetrag für aufgeschlüsselten Beleg entfernt (zuvor „${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `maximalen Ausgabenbetrag auf „${newValue}“ festlegen`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `hat den maximalen Ausgabenbetrag auf „${newValue}“ geändert (zuvor „${oldValue}“)`, removedMaxExpenseAmount: (oldValue: string) => `maximale Ausgabenhöhe entfernt (zuvor „${oldValue}“)`, diff --git a/src/languages/en.ts b/src/languages/en.ts index e4c5028b6235..5a11a64675a3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7383,6 +7383,9 @@ const translations = { setReceiptRequiredAmount: (newValue: string) => `set receipt required amount to "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `changed receipt required amount to "${newValue}" (previously "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `removed receipt required amount (previously "${oldValue}")`, + setItemizedReceiptRequiredAmount: (newValue: string) => `set itemized receipt required amount to "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `changed itemized receipt required amount to "${newValue}" (previously "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `removed itemized receipt required amount (previously "${oldValue}")`, setMaxExpenseAmount: (newValue: string) => `set max expense amount to "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `changed max expense amount to "${newValue}" (previously "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `removed max expense amount (previously "${oldValue}")`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 1e9a38ec17bf..3df3f3d4dacc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6948,6 +6948,9 @@ ${amount} para ${merchant} - ${date}`, setReceiptRequiredAmount: (newValue) => `estableció el importe requerido del recibo en "${newValue}"`, changedReceiptRequiredAmount: (oldValue, newValue) => `cambió el importe requerido del recibo a "${newValue}" (antes "${oldValue}")`, removedReceiptRequiredAmount: (oldValue) => `eliminó el importe requerido del recibo (antes "${oldValue}")`, + setItemizedReceiptRequiredAmount: (newValue) => `estableció el importe requerido del recibo detallado en "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue, newValue) => `cambió el importe requerido del recibo detallado a "${newValue}" (antes "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue) => `eliminó el importe requerido del recibo detallado (antes "${oldValue}")`, setMaxExpenseAmount: (newValue) => `estableció el importe máximo del gasto en "${newValue}"`, changedMaxExpenseAmount: (oldValue, newValue) => `cambió el importe máximo del gasto a "${newValue}" (antes "${oldValue}")`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 82f6c79bcdb6..9873e8aa5812 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7300,6 +7300,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setReceiptRequiredAmount: (newValue: string) => `définir le montant de reçu obligatoire sur « ${newValue} »`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `a modifié le montant requis pour le reçu à « ${newValue} » (auparavant « ${oldValue} »)`, removedReceiptRequiredAmount: (oldValue: string) => `a supprimé le montant requis pour le reçu (précédemment « ${oldValue} »)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `a défini le montant requis pour le reçu détaillé sur « ${newValue} »`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `a modifié le montant requis pour le reçu détaillé à « ${newValue} » (auparavant « ${oldValue} »)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `a supprimé le montant requis pour le reçu détaillé (précédemment « ${oldValue} »)`, setMaxExpenseAmount: (newValue: string) => `définir le montant maximal de dépense sur « ${newValue} »`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `montant maximal de dépense modifié en « ${newValue} » (précédemment « ${oldValue} »)`, removedMaxExpenseAmount: (oldValue: string) => `montant de dépense maximal supprimé (précédemment « ${oldValue} »)`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 4a3ad959ec29..1beb48f2e810 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7265,6 +7265,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setReceiptRequiredAmount: (newValue: string) => `imposta l’importo richiesto per la ricevuta su "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `ha modificato l’importo richiesto per la ricevuta in "${newValue}" (in precedenza "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `ha rimosso l’importo richiesto per la ricevuta (in precedenza «${oldValue}»)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `ha impostato l’importo richiesto per la ricevuta dettagliata su "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => + `ha modificato l’importo richiesto per la ricevuta dettagliata in "${newValue}" (in precedenza "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `ha rimosso l’importo richiesto per la ricevuta dettagliata (in precedenza "${oldValue}")`, setMaxExpenseAmount: (newValue: string) => `imposta l’importo massimo della spesa su "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `ha modificato l'importo massimo della spesa a "${newValue}" (precedentemente "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `importo massimo spesa rimosso (precedentemente "${oldValue}")`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b7460368b0e3..ea0a0ef334f9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7179,6 +7179,9 @@ ${reportName} setReceiptRequiredAmount: (newValue: string) => `必要な領収書金額を「${newValue}」に設定`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `領収書必須金額を「${newValue}」(以前は「${oldValue}」)に変更しました`, removedReceiptRequiredAmount: (oldValue: string) => `必須領収書金額を削除しました(以前の値:「${oldValue}」)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `明細付き領収書の必須金額を「${newValue}」に設定しました`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `明細付き領収書の必須金額を「${newValue}」に変更しました(以前は「${oldValue}」)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `明細付き領収書の必須金額を削除しました(以前の値:「${oldValue}」)`, setMaxExpenseAmount: (newValue: string) => `最大経費金額を「${newValue}」に設定`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `上限経費額を「${newValue}」に変更しました(以前は「${oldValue}」)`, removedMaxExpenseAmount: (oldValue: string) => `最大経費金額を削除しました(以前の値: 「${oldValue}」)`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 82eec026635c..6b35ab68efb7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7244,6 +7244,10 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setReceiptRequiredAmount: (newValue: string) => `stel het vereiste bonbedrag in op "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `heeft het vereiste bonbedrag gewijzigd naar "${newValue}" (voorheen "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `vereiste bedrag op verwijderde bon (voorheen “${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `vereist bedrag voor gespecificeerde bon ingesteld op “${newValue}”`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => + `heeft het vereiste bedrag voor gespecificeerde bon gewijzigd naar “${newValue}” (voorheen “${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `vereist bedrag voor gespecificeerde bon verwijderd (voorheen “${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `maximumbedrag voor uitgave instellen op "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `maximale onkostensom gewijzigd naar "${newValue}" (voorheen "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `maximaal bedrag voor uitgave verwijderd (voorheen "${oldValue}")`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fad2436ea5bc..735ec0751961 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7233,6 +7233,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setReceiptRequiredAmount: (newValue: string) => `ustaw wymaganą kwotę paragonu na „${newValue}”`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `zmienił(a) wymaganą kwotę paragonu na „${newValue}” (wcześniej „${oldValue}”)`, removedReceiptRequiredAmount: (oldValue: string) => `usunięto wymagany limit paragonu (wcześniej „${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `ustawiono wymaganą kwotę szczegółowego paragonu na „${newValue}”`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `zmieniono wymaganą kwotę szczegółowego paragonu na „${newValue}” (wcześniej „${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `usunięto wymaganą kwotę szczegółowego paragonu (wcześniej „${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `ustaw maksymalną kwotę wydatku na „${newValue}”`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `zmieniono maksymalną kwotę wydatku na „${newValue}” (wcześniej „${oldValue}”)`, removedMaxExpenseAmount: (oldValue: string) => `usunięto maksymalną kwotę wydatku (wcześniej „${oldValue}”)`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b50ded52c6e8..3cc9af5e4e4f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7236,6 +7236,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setReceiptRequiredAmount: (newValue: string) => `definir valor exigido do recibo como "${newValue}"`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `alterou o valor obrigatório do recibo para "${newValue}" (antes "${oldValue}")`, removedReceiptRequiredAmount: (oldValue: string) => `removeu o valor obrigatório do recibo (antes era "${oldValue}")`, + setItemizedReceiptRequiredAmount: (newValue: string) => `definiu o valor obrigatório do recibo detalhado como "${newValue}"`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `alterou o valor obrigatório do recibo detalhado para "${newValue}" (antes "${oldValue}")`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `removeu o valor obrigatório do recibo detalhado (antes era "${oldValue}")`, setMaxExpenseAmount: (newValue: string) => `definir valor máximo da despesa como "${newValue}"`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `alterou o valor máximo da despesa para "${newValue}" (antes "${oldValue}")`, removedMaxExpenseAmount: (oldValue: string) => `removeu o valor máximo de despesa (anteriormente "${oldValue}")`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index acf4ea70a06f..2423fe837a3b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7044,7 +7044,10 @@ ${reportName} }, setReceiptRequiredAmount: (newValue: string) => `将需要收据的金额设置为“${newValue}”`, changedReceiptRequiredAmount: (oldValue: string, newValue: string) => `已将所需收据金额更改为“${newValue}”(之前为“${oldValue}”)`, - removedReceiptRequiredAmount: (oldValue: string) => `已移除所需收据金额(先前为“${oldValue}”)`, + removedReceiptRequiredAmount: (oldValue: string) => `已移除所需收据金额(先前为”${oldValue}”)`, + setItemizedReceiptRequiredAmount: (newValue: string) => `将逐项收据所需金额设置为”${newValue}”`, + changedItemizedReceiptRequiredAmount: (oldValue: string, newValue: string) => `已将逐项收据所需金额更改为”${newValue}”(之前为”${oldValue}”)`, + removedItemizedReceiptRequiredAmount: (oldValue: string) => `已移除逐项收据所需金额(先前为”${oldValue}”)`, setMaxExpenseAmount: (newValue: string) => `将最高报销金额设置为“${newValue}”`, changedMaxExpenseAmount: (oldValue: string, newValue: string) => `将最高报销金额更改为 “${newValue}”(之前为 “${oldValue}”)`, removedMaxExpenseAmount: (oldValue: string) => `已移除最⾼报销金额(原为“${oldValue}”)`, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1c31711ce7a7..10ea68413baa 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -3612,6 +3612,30 @@ function getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate: Localized return getReportActionText(action); } +function getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage(translate: LocalizedTranslate, action: ReportAction): string { + const {oldMaxExpenseAmountNoItemizedReceipt, newMaxExpenseAmountNoItemizedReceipt, currency} = + getOriginalMessage(action as ReportAction) ?? {}; + + if (typeof oldMaxExpenseAmountNoItemizedReceipt === 'number' && typeof newMaxExpenseAmountNoItemizedReceipt === 'number') { + const isOldLimitDisabled = oldMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const isNewLimitDisabled = newMaxExpenseAmountNoItemizedReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; + const oldValue = isOldLimitDisabled ? '' : convertToDisplayString(oldMaxExpenseAmountNoItemizedReceipt, currency); + const newValue = isNewLimitDisabled ? '' : convertToDisplayString(newMaxExpenseAmountNoItemizedReceipt, currency); + + if (isOldLimitDisabled && !isNewLimitDisabled) { + return translate('workspaceActions.setItemizedReceiptRequiredAmount', newValue); + } + + if (!isOldLimitDisabled && isNewLimitDisabled) { + return translate('workspaceActions.removedItemizedReceiptRequiredAmount', oldValue); + } + + return translate('workspaceActions.changedItemizedReceiptRequiredAmount', oldValue, newValue); + } + + return getReportActionText(action); +} + function getPolicyChangeLogMaxExpenseAmountMessage(translate: LocalizedTranslate, action: ReportAction): string { const {oldMaxExpenseAmount, newMaxExpenseAmount, currency} = getOriginalMessage(action as ReportAction) ?? {}; @@ -3927,7 +3951,7 @@ function getUpdatedManualApprovalThresholdMessage(translate: LocalizedTranslate, currency = CONST.CURRENCY.USD, } = getOriginalMessage(reportAction as ReportAction) ?? {}; - if (typeof oldLimit !== 'number' || typeof oldLimit !== 'number') { + if (typeof oldLimit !== 'number' || typeof newLimit !== 'number') { return getReportActionText(reportAction); } return translate('workspaceActions.updatedManualApprovalThreshold', {oldLimit: convertToDisplayString(oldLimit, currency), newLimit: convertToDisplayString(newLimit, currency)}); @@ -4684,6 +4708,7 @@ export { getForeignCurrencyDefaultTaxUpdateMessage, getWorkspaceFrequencyUpdateMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountMessage, getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogDefaultBillableMessage, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index fe3de00aec9a..85362be9e161 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -72,6 +72,7 @@ import { getPolicyChangeLogEmployeeLeftMessage, getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogUpdateEmployee, getReimburserUpdateMessage, @@ -1036,6 +1037,8 @@ function getOptionData({ result.alternateText = getCompanyAddressUpdateMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT) { result.alternateText = getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT) { + result.alternateText = getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT) { result.alternateText = getPolicyChangeLogMaxExpenseAmountMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE) { diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index ca19344d56b7..153ec506951e 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -118,6 +118,7 @@ import { getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogMaxExpenseAgeMessage, getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogMaxExpenseAmountNoItemizedReceiptMessage, getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getPolicyChangeLogUpdateEmployee, getReimburserUpdateMessage, @@ -1626,6 +1627,8 @@ function PureReportActionItem({ children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT)) { children = ; + } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_ITEMIZED_RECEIPT)) { + children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE)) { diff --git a/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx b/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx index 77ff0a98d0a4..9e139591a2ab 100644 --- a/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx +++ b/src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -26,7 +26,6 @@ function AddPersonalNewCardPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const {currentStep} = addNewPersonalCardFeed ?? {}; - const [isModalVisible, setIsModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); const {translate} = useLocalize(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -66,29 +65,24 @@ function AddPersonalNewCardPage() { CurrentStep = ( { - setIsModalVisible(true); showConfirmModal({ title: translate('workspace.companyCards.addNewCard.exitModal.title'), success: true, confirmText: translate('workspace.companyCards.addNewCard.exitModal.confirmText'), cancelText: translate('workspace.companyCards.addNewCard.exitModal.cancelText'), prompt: translate('workspace.companyCards.addNewCard.exitModal.prompt'), - }) - .then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, false, betas); - }) - .finally(() => { - setIsModalVisible(false); - }); + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, false, betas); + }); }} /> ); break; default: - CurrentStep = ; + CurrentStep = ; break; } diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 41b63bc21800..7fc49006c20b 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -475,6 +475,12 @@ type OriginalMessagePolicyChangeLog = { /** New value for max expense amount with no receipt */ newMaxExpenseAmountNoReceipt?: number; + /** Old value for max expense amount with no itemized receipt */ + oldMaxExpenseAmountNoItemizedReceipt?: number; + + /** New value for max expense amount with no itemized receipt */ + newMaxExpenseAmountNoItemizedReceipt?: number; + /** Currency of the policy */ currency?: string; From c22e6914d07883fb00bdccc5ef1efcc1cd074e84 Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Thu, 9 Apr 2026 19:05:51 +0000 Subject: [PATCH 7/8] Remove orphaned expo-secure-store patches The expo-secure-store package was removed from dependencies but patch files remained after a merge, causing patch-package to fail during CI with ENOENT on node_modules/expo-secure-store/.patch-package.json. Co-authored-by: Daniel Gale-Rosen --- patches/expo-secure-store/details.md | 76 -- ...re+14.2.4+001+enable-device-fallback.patch | 435 --------- ...re-store+14.2.4+002+return-auth-type.patch | 901 ------------------ ...2.4+003+force-authentication-on-save.patch | 124 --- ...cure-store+14.2.4+004+fail-on-update.patch | 100 -- ...ce-read-authentication-on-simulators.patch | 77 -- 6 files changed, 1713 deletions(-) delete mode 100644 patches/expo-secure-store/details.md delete mode 100644 patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch delete mode 100644 patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch delete mode 100644 patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch delete mode 100644 patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch delete mode 100644 patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch diff --git a/patches/expo-secure-store/details.md b/patches/expo-secure-store/details.md deleted file mode 100644 index d6d176d012a1..000000000000 --- a/patches/expo-secure-store/details.md +++ /dev/null @@ -1,76 +0,0 @@ -# `expo-secure-store` patches - -### [expo-secure-store+14.2.4+001+enable-device-fallback.patch](expo-secure-store+14.2.4+001+enable-device-fallback.patch) - -- Reason: - - ``` - We need to enable users to use any device screen lock instead of biometrics. - This is not enabled in the SecureStore, so this patch changes the required input to either biometrics or screen lock knowledge if the 'enableDeviceFallback' flag is set to true. - Additionally, support for screen locks can be checked using the new 'canUseDeviceCredentialsAuthentication' method. - ``` - -- Upstream PR/issue: https://github.com/expo/expo/pull/41409 -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+002+return-auth-type.patch](expo-secure-store+14.2.4+002+return-auth-type.patch) - -- Reason: - - ``` - This patch adds the `returnUsedAuthenticationType` flag. - When this flag is set to true, the get methods of SecureStore will return a two-element array. - The first value will be the original value returned when this flag is set to false. - The second value is the authentication type used to read the value from the AUTH_TYPE object. - As for the set function, the returned value will simply be AUTH_TYPE. - It uses a pre-defined constant that mimics an enum and can also be imported directly from the app. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+003+force-authentication-on-save.patch](expo-secure-store+14.2.4+003+force-authentication-on-save.patch) - -- Reason: - - ``` - The iOS does not require authentication when a value is saved to the keychain. We cannot force iOS to do so. - However, to maintain consistency with Android, setting the 'forceAuthenticationOnSave' flag to true results in a prompt appearing - before the value is saved. This only works in the asynchronous version of the save method. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+004+fail-on-update.patch](expo-secure-store+14.2.4+004+fail-on-update.patch) - -- Reason: - - ``` - If a value already exists in the SecureStore and a new one is saved, the existing value is simply overwritten. - To avoid unexpected behaviour, set the 'failOnUpdate' flag to true to trigger an error when the given key is already in the store. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch](expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch) - -- Reason: - - ``` - The LocalAuthentication behaves slightly differently on iOS simulators. - In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). - Setting 'forceReadAuthenticationOnSimulators' flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. - The flag is added is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. - It has no effect on real devices. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch deleted file mode 100644 index d5d5edad1a25..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch +++ /dev/null @@ -1,435 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -index a281b88..4a1a009 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -@@ -2,6 +2,7 @@ package expo.modules.securestore - - import android.annotation.SuppressLint - import android.app.Activity -+import android.app.KeyguardManager - import android.content.Context - import android.os.Build - import androidx.biometric.BiometricManager -@@ -19,9 +20,9 @@ class AuthenticationHelper( - ) { - private var isAuthenticating = false - -- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String): Cipher { -+ suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String, enableDeviceFallback: Boolean): Cipher { - if (requiresAuthentication) { -- return openAuthenticationPrompt(cipher, title).cryptoObject?.cipher -+ return openAuthenticationPrompt(cipher, title, enableDeviceFallback).cryptoObject?.cipher - ?: throw AuthenticationException("Couldn't get cipher from authentication result") - } - return cipher -@@ -29,7 +30,8 @@ class AuthenticationHelper( - - private suspend fun openAuthenticationPrompt( - cipher: Cipher, -- title: String -+ title: String, -+ enableDeviceFallback: Boolean - ): BiometricPrompt.AuthenticationResult { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - throw AuthenticationException("Biometric authentication requires Android API 23") -@@ -41,11 +43,16 @@ class AuthenticationHelper( - isAuthenticating = true - - try { -- assertBiometricsSupport() -+ if (enableDeviceFallback) { -+ assertDeviceSecurity() -+ } else { -+ assertBiometricsSupport() -+ } -+ - val fragmentActivity = getCurrentActivity() as? FragmentActivity - ?: throw AuthenticationException("Cannot display biometric prompt when the app is not in the foreground") - -- val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title) -+ val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title, enableDeviceFallback) - - return withContext(Dispatchers.Main.immediate) { - return@withContext authenticationPrompt.authenticate(cipher) -@@ -56,6 +63,14 @@ class AuthenticationHelper( - } - } - -+ fun assertDeviceSecurity() { -+ val manager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager -+ val isSecure = manager.isDeviceSecure -+ if (!isSecure) { -+ throw AuthenticationException("No authentication method available") -+ } -+ } -+ - fun assertBiometricsSupport() { - val biometricManager = BiometricManager.from(context) - @SuppressLint("SwitchIntDef") // BiometricManager.BIOMETRIC_SUCCESS shouldn't do anything -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -index e5729cc..ce448d6 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -@@ -1,5 +1,7 @@ - package expo.modules.securestore - -+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL - import android.content.Context - import androidx.biometric.BiometricPrompt - import androidx.biometric.BiometricPrompt.PromptInfo -@@ -11,12 +13,23 @@ import kotlin.coroutines.resume - import kotlin.coroutines.resumeWithException - import kotlin.coroutines.suspendCoroutine - --class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) { -+class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String, enableDeviceFallback: Boolean) { -+ private var authType: Int = if (enableDeviceFallback) BIOMETRIC_STRONG or DEVICE_CREDENTIAL else BIOMETRIC_STRONG - private var executor: Executor = ContextCompat.getMainExecutor(context) -- private var promptInfo = PromptInfo.Builder() -- .setTitle(title) -- .setNegativeButtonText(context.getString(android.R.string.cancel)) -- .build() -+ private var promptInfo = buildPromptInfo(context, title, enableDeviceFallback) -+ -+ private fun buildPromptInfo(context: Context, title: String, enableDeviceFallback: Boolean): PromptInfo { -+ var prompt = PromptInfo.Builder() -+ .setTitle(title) -+ .setAllowedAuthenticators(authType) -+ -+ if (!enableDeviceFallback) { -+ prompt = prompt. -+ setNegativeButtonText(context.getString(android.R.string.cancel)) -+ } -+ -+ return prompt.build() -+ } - - suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? = - suspendCoroutine { continuation -> -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index 0fef884..bf9689b 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -75,6 +75,15 @@ open class SecureStoreModule : Module() { - } - } - -+ Function("canUseDeviceCredentialsAuthentication") { -+ return@Function try { -+ authenticationHelper.assertDeviceSecurity() -+ true -+ } catch (e: AuthenticationException) { -+ false -+ } -+ } -+ - OnCreate { - authenticationHelper = AuthenticationHelper(reactContext, appContext.legacyModuleRegistry) - hybridAESEncryptor = HybridAESEncryptor(reactContext, mAESEncryptor) -@@ -201,7 +210,7 @@ open class SecureStoreModule : Module() { - back a value. - */ - val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) -- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper) -+ val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) - encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) - saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) - -@@ -343,7 +352,11 @@ open class SecureStoreModule : Module() { - return getKeyEntry(keyStoreEntryClass, encryptor, options, requireAuthentication) ?: run { - // Android won't allow us to generate the keys if the device doesn't support biometrics or no biometrics are enrolled - if (requireAuthentication) { -- authenticationHelper.assertBiometricsSupport() -+ if (options.enableDeviceFallback) { -+ authenticationHelper.assertDeviceSecurity() -+ } else { -+ authenticationHelper.assertBiometricsSupport() -+ } - } - encryptor.initializeKeyStoreEntry(keyStore, options) - } -@@ -383,6 +396,7 @@ open class SecureStoreModule : Module() { - private const val KEYSTORE_ALIAS_PROPERTY = "keystoreAlias" - const val USES_KEYSTORE_SUFFIX_PROPERTY = "usesKeystoreSuffix" - const val DEFAULT_KEYSTORE_ALIAS = "key_v1" -+ const val DEFAULT_FALLBACK_KEYSTORE_ALIAS = "fallback_key_v1" - const val AUTHENTICATED_KEYSTORE_SUFFIX = "keystoreAuthenticated" - const val UNAUTHENTICATED_KEYSTORE_SUFFIX = "keystoreUnauthenticated" - } -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 79a600f..0455fd6 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -8,5 +8,6 @@ class SecureStoreOptions( - // Prompt can't be an empty string - @Field var authenticationPrompt: String = " ", - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, -- @Field var requireAuthentication: Boolean = false -+ @Field var requireAuthentication: Boolean = false, -+ @Field var enableDeviceFallback: Boolean = false - ) : Record, Serializable -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -index 3a12dc9..1cec6c0 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -@@ -1,9 +1,10 @@ - package expo.modules.securestore.encryptors - --import android.annotation.TargetApi -+import android.os.Build - import android.security.keystore.KeyGenParameterSpec - import android.security.keystore.KeyProperties - import android.util.Base64 -+import androidx.annotation.RequiresApi - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.DecryptException - import expo.modules.securestore.SecureStoreModule -@@ -33,6 +34,11 @@ import javax.crypto.spec.GCMParameterSpec - class AESEncryptor : KeyBasedEncryptor { - override fun getKeyStoreAlias(options: SecureStoreOptions): String { - val baseAlias = options.keychainService -+ -+ if (baseAlias == SecureStoreModule.DEFAULT_KEYSTORE_ALIAS && options.enableDeviceFallback) { -+ return "$AES_CIPHER:${SecureStoreModule.DEFAULT_FALLBACK_KEYSTORE_ALIAS}" -+ } -+ - return "$AES_CIPHER:$baseAlias" - } - -@@ -50,17 +56,22 @@ class AESEncryptor : KeyBasedEncryptor { - return "${getKeyStoreAlias(options)}:$suffix" - } - -- @TargetApi(23) -+ @RequiresApi(Build.VERSION_CODES.R) - @Throws(GeneralSecurityException::class) - override fun initializeKeyStoreEntry(keyStore: KeyStore, options: SecureStoreOptions): KeyStore.SecretKeyEntry { - val extendedKeystoreAlias = getExtendedKeyStoreAlias(options, options.requireAuthentication) - val keyPurposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - -+ val authType = -+ if (options.enableDeviceFallback) KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL -+ else KeyProperties.AUTH_BIOMETRIC_STRONG -+ - val algorithmSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(extendedKeystoreAlias, keyPurposes) - .setKeySize(AES_KEY_SIZE_BITS) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setUserAuthenticationRequired(options.requireAuthentication) -+ .setUserAuthenticationParameters(0, authType) - .build() - - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider) -@@ -78,14 +89,15 @@ class AESEncryptor : KeyBasedEncryptor { - keyStoreEntry: KeyStore.SecretKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -+ authenticationHelper: AuthenticationHelper, -+ enableDeviceFallback: Boolean, - ): JSONObject { - val secretKey = keyStoreEntry.secretKey - val cipher = Cipher.getInstance(AES_CIPHER) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) -- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt) -+ val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt, enableDeviceFallback) - - return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) - } -@@ -128,7 +140,7 @@ class AESEncryptor : KeyBasedEncryptor { - throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) - } - cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) -- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt) -+ val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt, options.enableDeviceFallback) - return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) - } - -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -index fb42599..e996b39 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -@@ -51,6 +51,11 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - - override fun getKeyStoreAlias(options: SecureStoreOptions): String { - val baseAlias = options.keychainService -+ -+ if (baseAlias == SecureStoreModule.DEFAULT_KEYSTORE_ALIAS && options.enableDeviceFallback) { -+ return "$RSA_CIPHER:${SecureStoreModule.DEFAULT_FALLBACK_KEYSTORE_ALIAS}" -+ } -+ - return "$RSA_CIPHER:$baseAlias" - } - -@@ -69,7 +74,8 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - keyStoreEntry: KeyStore.PrivateKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -+ authenticationHelper: AuthenticationHelper, -+ enableDeviceFallback: Boolean, - ): JSONObject { - // This should never be called after we dropped Android SDK 22 support. - throw EncryptException( -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -index e493467..39459ff 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -@@ -25,7 +25,8 @@ interface KeyBasedEncryptor { - keyStoreEntry: E, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -+ authenticationHelper: AuthenticationHelper, -+ enableDeviceFallback: Boolean, - ): JSONObject - - @Throws(GeneralSecurityException::class, JSONException::class) -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index d5cd157..835a4d4 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -78,6 +78,16 @@ export type SecureStoreOptions = { - * @platform ios - */ - accessGroup?: string; -+ /** -+ * This flag enables users to authenticate using Lock Screen Knowledge Factor (e.g. PIN, pattern or password). -+ * For sensitive apps, it is recommended not having biometric fall back to such factor. -+ * @see: https://developer.android.com/security/fraud-prevention/authentication -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ enableDeviceFallback?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -@@ -148,4 +158,11 @@ export declare function getItem(key: string, options?: SecureStoreOptions): stri - * @platform ios - */ - export declare function canUseBiometricAuthentication(): boolean; -+/** -+ * Checks whether any device credentials are configured on the device. -+ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. -+ * @platform android -+ * @platform ios -+ */ -+export declare function canUseDeviceCredentialsAuthentication(): boolean; - //# sourceMappingURL=SecureStore.d.ts.map -\ No newline at end of file -diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js -index 4d87b38..e11bd94 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.js -+++ b/node_modules/expo-secure-store/build/SecureStore.js -@@ -143,6 +143,15 @@ export function getItem(key, options = {}) { - export function canUseBiometricAuthentication() { - return ExpoSecureStore.canUseBiometricAuthentication(); - } -+/** -+ * Checks whether any device credentials are configured on the device. -+ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. -+ * @platform android -+ * @platform ios -+ */ -+export function canUseDeviceCredentialsAuthentication() { -+ return ExpoSecureStore.canUseDeviceCredentialsAuthentication(); -+} - function ensureValidKey(key) { - if (!isValidKey(key)) { - throw new Error(`Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".`); -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 439b08d..1268979 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -66,6 +66,14 @@ public final class SecureStoreModule: Module { - return isBiometricsSupported - #endif - } -+ -+ Function("canUseDeviceCredentialsAuthentication") { () -> Bool in -+ return areDeviceCredentialsEnabled() -+ } -+ } -+ -+ private func areDeviceCredentialsEnabled() -> Bool { -+ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthentication, error: nil) - } - - private func get(with key: String, options: SecureStoreOptions) throws -> String? { -@@ -99,12 +107,17 @@ public final class SecureStoreModule: Module { - if !options.requireAuthentication { - setItemQuery[kSecAttrAccessible as String] = accessibility - } else { -- guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { -- throw MissingPlistKeyException() -+ if (!options.enableDeviceFallback) { -+ guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { -+ throw MissingPlistKeyException() -+ } - } - - var error: Unmanaged? = nil -- guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, .biometryCurrentSet, &error) else { -+ -+ let accessControlFlag: SecAccessControlCreateFlags = options.enableDeviceFallback ? .userPresence : .biometryCurrentSet -+ -+ guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, accessControlFlag, &error) else { - let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } - throw SecAccessControlError(errorCode) - } -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 7e3fa4d..c9e843b 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -15,4 +15,7 @@ internal struct SecureStoreOptions: Record { - - @Field - var accessGroup: String? -+ -+ @Field -+ var enableDeviceFallback: Bool = false - } -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index ee43e04..4f253a1 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -102,6 +102,17 @@ export type SecureStoreOptions = { - * @platform ios - */ - accessGroup?: string; -+ -+ /** -+ * This flag enables users to authenticate using Lock Screen Knowledge Factor (e.g. PIN, pattern or password). -+ * For sensitive apps, it is recommended not having biometric fall back to such factor. -+ * @see: https://developer.android.com/security/fraud-prevention/authentication -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ enableDeviceFallback?: boolean; - }; - - // @needsAudit -@@ -226,6 +237,16 @@ export function canUseBiometricAuthentication(): boolean { - return ExpoSecureStore.canUseBiometricAuthentication(); - } - -+/** -+ * Checks whether any device credentials are configured on the device. -+ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. -+ * @platform android -+ * @platform ios -+ */ -+export function canUseDeviceCredentialsAuthentication(): boolean { -+ return ExpoSecureStore.canUseDeviceCredentialsAuthentication(); -+} -+ - function ensureValidKey(key: string) { - if (!isValidKey(key)) { - throw new Error( diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch deleted file mode 100644 index c8cdfcf5ac02..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch +++ /dev/null @@ -1,901 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -index 4a1a009..980ef0a 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -@@ -20,12 +20,12 @@ class AuthenticationHelper( - ) { - private var isAuthenticating = false - -- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String, enableDeviceFallback: Boolean): Cipher { -- if (requiresAuthentication) { -- return openAuthenticationPrompt(cipher, title, enableDeviceFallback).cryptoObject?.cipher -- ?: throw AuthenticationException("Couldn't get cipher from authentication result") -+ suspend fun authenticateCipher(cipher: Cipher, title: String, enableDeviceFallback: Boolean): BiometricPrompt.AuthenticationResult { -+ val promptResult = openAuthenticationPrompt(cipher, title, enableDeviceFallback) -+ if (promptResult.cryptoObject?.cipher == null) { -+ throw AuthenticationException("Couldn't get cipher from authentication result") - } -- return cipher -+ return promptResult - } - - private suspend fun openAuthenticationPrompt( -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index 3f21552..d209efc 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -36,23 +36,23 @@ open class SecureStoreModule : Module() { - - AsyncFunction("setValueWithKeyAsync") Coroutine { value: String?, key: String?, options: SecureStoreOptions -> - key ?: throw NullKeyException() -- return@Coroutine setItemImpl(key, value, options, false) -+ return@Coroutine narrowSecureStoreFeedback(SecureStoreFeedbackAction.SET, setItemImpl(key, value, options, false), options).value - } - - AsyncFunction("getValueWithKeyAsync") Coroutine { key: String, options: SecureStoreOptions -> -- return@Coroutine getItemImpl(key, options) -+ return@Coroutine narrowSecureStoreFeedback(SecureStoreFeedbackAction.GET,getItemImpl(key, options), options).value - } - - Function("setValueWithKeySync") { value: String?, key: String?, options: SecureStoreOptions -> - key ?: throw NullKeyException() - return@Function runBlocking { -- return@runBlocking setItemImpl(key, value, options, keyIsInvalidated = false) -+ return@runBlocking narrowSecureStoreFeedback(SecureStoreFeedbackAction.SET, setItemImpl(key, value, options, keyIsInvalidated = false), options).value - } - } - - Function("getValueWithKeySync") { key: String, options: SecureStoreOptions -> - return@Function runBlocking { -- return@runBlocking getItemImpl(key, options) -+ return@runBlocking narrowSecureStoreFeedback(SecureStoreFeedbackAction.GET, getItemImpl(key, options), options).value - } - } - -@@ -94,7 +94,23 @@ open class SecureStoreModule : Module() { - } - } - -- private suspend fun getItemImpl(key: String, options: SecureStoreOptions): String? { -+ private suspend fun narrowSecureStoreFeedback(action: String, feedback: SecureStoreOriginalFeedback, options: SecureStoreOptions): SecureStoreNarrowedFeedback { -+ if (!options.returnUsedAuthenticationType) { -+ return feedback -+ } -+ -+ if (action == SecureStoreFeedbackAction.GET) { -+ return SecureStoreGetFeedback(feedback.source, feedback.authenticationResult) -+ } -+ -+ if (action == SecureStoreFeedbackAction.SET) { -+ return SecureStoreSetFeedback(feedback.source, feedback.authenticationResult) -+ } -+ -+ return feedback -+ } -+ -+ private suspend fun getItemImpl(key: String, options: SecureStoreOptions): SecureStoreOriginalFeedback { - // We use a SecureStore-specific shared preferences file, which lets us do things like enumerate - // its entries or clear all of them - val prefs: SharedPreferences = getSharedPreferences() -@@ -104,10 +120,10 @@ open class SecureStoreModule : Module() { - } else if (prefs.contains(key)) { // For backwards-compatibility try to read using the old key format - return readJSONEncodedItem(key, prefs, options) - } -- return null -+ return SecureStoreOriginalFeedback(null) - } - -- private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): String? { -+ private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): SecureStoreOriginalFeedback { - val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) - - val legacyEncryptedItemString = prefs.getString(key, null) -@@ -126,7 +142,7 @@ open class SecureStoreModule : Module() { - "" - } - -- encryptedItemString ?: return null -+ encryptedItemString ?: return SecureStoreOriginalFeedback(null) - - val encryptedItem: JSONObject = try { - JSONObject(encryptedItemString) -@@ -149,13 +165,13 @@ open class SecureStoreModule : Module() { - "This situation occurs when the app is reinstalled. The value will be removed to avoid future errors. Returning null" - ) - deleteItemImpl(key, options) -- return null -+ return SecureStoreOriginalFeedback(null) - } - return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper) - } - HybridAESEncryptor.NAME -> { - val privateKeyEntry = getKeyEntryCompat(PrivateKeyEntry::class.java, hybridAESEncryptor, options, requireAuthentication, usesKeystoreSuffix) -- ?: return null -+ ?: return SecureStoreOriginalFeedback(null) - return hybridAESEncryptor.decryptItem(key, encryptedItem, privateKeyEntry, options, authenticationHelper) - } - else -> { -@@ -164,7 +180,7 @@ open class SecureStoreModule : Module() { - } - } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "The requested key has been permanently invalidated. Returning null") -- return null -+ return SecureStoreOriginalFeedback(null) - } catch (e: BadPaddingException) { - // The key from the KeyStore is unable to decode the entry. This is because a new key was generated, but the entries are encrypted using the old one. - // This usually means that the user has reinstalled the app. We can safely remove the old value and return null as it's impossible to decrypt it. -@@ -174,7 +190,7 @@ open class SecureStoreModule : Module() { - "The entry in shared preferences is out of sync with the keystore. It will be removed, returning null." - ) - deleteItemImpl(key, options) -- return null -+ return SecureStoreOriginalFeedback(null) - } catch (e: GeneralSecurityException) { - throw (DecryptException(e.message, key, options.keychainService, e)) - } catch (e: CodedException) { -@@ -184,7 +200,7 @@ open class SecureStoreModule : Module() { - } - } - -- private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean) { -+ private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean): SecureStoreOriginalFeedback { - val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) - val prefs: SharedPreferences = getSharedPreferences() - -@@ -193,7 +209,7 @@ open class SecureStoreModule : Module() { - if (!success) { - throw WriteException("Could not write a null value to SecureStore", key, options.keychainService) - } -- return -+ return SecureStoreOriginalFeedback(null) - } - - try { -@@ -210,7 +226,8 @@ open class SecureStoreModule : Module() { - back a value. - */ - val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) -- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) -+ val encryptResult = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) -+ val encryptedItem = encryptResult.value - encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) - saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) - -@@ -218,6 +235,9 @@ open class SecureStoreModule : Module() { - if (prefs.contains(key)) { - prefs.edit().remove(key).apply() - } -+ -+ return encryptResult -+ - } catch (e: KeyPermanentlyInvalidatedException) { - if (!keyIsInvalidated) { - Log.w(TAG, "Key has been invalidated, retrying with the key deleted") -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 0455fd6..4e30440 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -1,5 +1,6 @@ - package expo.modules.securestore - -+import androidx.biometric.BiometricPrompt - import expo.modules.kotlin.records.Field - import expo.modules.kotlin.records.Record - import java.io.Serializable -@@ -9,5 +10,58 @@ class SecureStoreOptions( - @Field var authenticationPrompt: String = " ", - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, - @Field var requireAuthentication: Boolean = false, -- @Field var enableDeviceFallback: Boolean = false -+ @Field var enableDeviceFallback: Boolean = false, -+ @Field var returnUsedAuthenticationType: Boolean = false - ) : Record, Serializable -+ -+enum class SecureStoreAuthType(index: Int) { -+ UNKNOWN(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN), -+ CREDENTIAL(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL), -+ BIOMETRIC(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC), -+ -+ /** Prompt failed, no authentication was used at all */ -+ NONE(0) -+} -+ -+open class SecureStoreFeedbackAction { -+ companion object { -+ const val GET = "GET" -+ const val SET = "SET" -+ } -+} -+ -+abstract class SecureStoreFeedback( -+ val source: T, -+ val authenticationResult: BiometricPrompt.AuthenticationResult? -+) { -+ @Field var authType: SecureStoreAuthType = when (authenticationResult?.authenticationType) { -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN -> { -+ SecureStoreAuthType.UNKNOWN -+ } -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL -> { -+ SecureStoreAuthType.CREDENTIAL -+ } -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC -> { -+ SecureStoreAuthType.BIOMETRIC -+ } -+ else -> { -+ SecureStoreAuthType.NONE -+ } -+ } -+ abstract val value: R -+} -+ -+class SecureStoreGetFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback>(source, authenticationResult) { -+ /** Used to return easily convertible values to JS code */ -+ @Field override var value: Pair = Pair(source, authType.ordinal) -+} -+ -+class SecureStoreSetFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback(source, authenticationResult) { -+ @Field override var value: Int = authType.ordinal -+} -+ -+class SecureStoreOriginalFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback(source, authenticationResult) { -+ @Field override var value: T = source -+} -+ -+typealias SecureStoreNarrowedFeedback = SecureStoreFeedback -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -index 1cd187f..d927ff1 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -@@ -5,10 +5,12 @@ import android.security.keystore.KeyGenParameterSpec - import android.security.keystore.KeyProperties - import android.util.Base64 - import androidx.annotation.RequiresApi -+import androidx.biometric.BiometricPrompt - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.DecryptException - import expo.modules.securestore.SecureStoreModule - import expo.modules.securestore.SecureStoreOptions -+import expo.modules.securestore.SecureStoreOriginalFeedback - import org.json.JSONException - import org.json.JSONObject - import java.nio.charset.StandardCharsets -@@ -86,15 +88,23 @@ class AESEncryptor : KeyBasedEncryptor { - authenticationPrompt: String, - authenticationHelper: AuthenticationHelper, - enableDeviceFallback: Boolean, -- ): JSONObject { -+ ): SecureStoreOriginalFeedback { - val secretKey = keyStoreEntry.secretKey - val cipher = Cipher.getInstance(AES_CIPHER) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) -- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt, enableDeviceFallback) -+ var promptResult: BiometricPrompt.AuthenticationResult? = null -+ val authenticatedCipher: Cipher -+ -+ if (requireAuthentication) { -+ promptResult = authenticationHelper.authenticateCipher(cipher, authenticationPrompt, enableDeviceFallback) -+ authenticatedCipher = promptResult.cryptoObject?.cipher ?: cipher -+ } else { -+ authenticatedCipher = cipher -+ } - -- return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) -+ return SecureStoreOriginalFeedback(createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec), promptResult) - } - - internal fun createEncryptedItemWithCipher( -@@ -121,7 +131,7 @@ class AESEncryptor : KeyBasedEncryptor { - keyStoreEntry: KeyStore.SecretKeyEntry, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String { -+ ): SecureStoreOriginalFeedback { - val ciphertext = encryptedItem.getString(CIPHERTEXT_PROPERTY) - val ivString = encryptedItem.getString(IV_PROPERTY) - val authenticationTagLength = encryptedItem.getInt(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY) -@@ -135,8 +145,17 @@ class AESEncryptor : KeyBasedEncryptor { - throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) - } - cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) -- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt, options.enableDeviceFallback) -- return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) -+ var promptResult: BiometricPrompt.AuthenticationResult? = null -+ val unlockedCipher: Cipher -+ -+ if (requiresAuthentication) { -+ promptResult = authenticationHelper.authenticateCipher(cipher, options.authenticationPrompt, options.enableDeviceFallback) -+ unlockedCipher = promptResult.cryptoObject?.cipher ?: cipher -+ } else { -+ unlockedCipher = cipher -+ } -+ -+ return SecureStoreOriginalFeedback(String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8), promptResult) - } - - companion object { -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -index 5f8bbfd..7527001 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -@@ -9,6 +9,7 @@ import expo.modules.securestore.EncryptException - import expo.modules.securestore.KeyStoreException - import expo.modules.securestore.SecureStoreModule - import expo.modules.securestore.SecureStoreOptions -+import expo.modules.securestore.SecureStoreOriginalFeedback - import org.json.JSONException - import org.json.JSONObject - import java.security.GeneralSecurityException -@@ -71,7 +72,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - authenticationPrompt: String, - authenticationHelper: AuthenticationHelper, - enableDeviceFallback: Boolean, -- ): JSONObject { -+ ): SecureStoreOriginalFeedback { - // This should never be called after we dropped Android SDK 22 support. - throw EncryptException( - "HybridAESEncryption should not be used on Android SDK >= 23. This shouldn't happen. " + -@@ -88,7 +89,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - keyStoreEntry: KeyStore.PrivateKeyEntry, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String { -+ ): SecureStoreOriginalFeedback { - // Decrypt the encrypted symmetric key - val encryptedSecretKeyString = encryptedItem.getString(ENCRYPTED_SECRET_KEY_PROPERTY) - val encryptedSecretKeyBytes = Base64.decode(encryptedSecretKeyString, Base64.DEFAULT) -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -index 39459ff..a531aff 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -@@ -2,6 +2,7 @@ package expo.modules.securestore.encryptors - - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.SecureStoreOptions -+import expo.modules.securestore.SecureStoreOriginalFeedback - import org.json.JSONException - import org.json.JSONObject - import java.security.GeneralSecurityException -@@ -27,7 +28,7 @@ interface KeyBasedEncryptor { - authenticationPrompt: String, - authenticationHelper: AuthenticationHelper, - enableDeviceFallback: Boolean, -- ): JSONObject -+ ): SecureStoreOriginalFeedback - - @Throws(GeneralSecurityException::class, JSONException::class) - suspend fun decryptItem( -@@ -36,5 +37,5 @@ interface KeyBasedEncryptor { - keyStoreEntry: E, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String -+ ): SecureStoreOriginalFeedback - } -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index 835a4d4..d2da8d9 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -1,4 +1,53 @@ -+type EmptyObject = Record; -+type SecureStoreSetFeedback = R['returnUsedAuthenticationType'] extends true ? AuthType : void; -+type SecureStoreGetFeedback = R['returnUsedAuthenticationType'] extends true ? [T | null, AuthType] : T | null; - export type KeychainAccessibilityConstant = number; -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export declare const AUTH_TYPE: { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ readonly UNKNOWN: -1; -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ readonly NONE: 0; -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ readonly CREDENTIALS: 1; -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ readonly BIOMETRICS: 2; -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ readonly FACE_ID: 3; -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ readonly TOUCH_ID: 4; -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ readonly OPTIC_ID: 5; -+}; -+type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; - /** - * The data in the keychain item cannot be accessed after a restart until the device has been - * unlocked once by the user. This may be useful if you need to access the item when the phone -@@ -88,6 +137,22 @@ export type SecureStoreOptions = { - * @platform ios - */ - enableDeviceFallback?: boolean; -+ /** -+ * When this flag is set to true, the get methods of SecureStore will return a two-element array. The first value will be the original value returned when this flag is set to false. -+ * The second value is the authentication type used to read the value from the AUTH_TYPE object. -+ * As for the set function, the returned value will simply be AUTH_TYPE. -+ * -+ * @warning -+ * If the iOS device supports biometrics and the user falls back to device credentials, it will not be detected. -+ * This is not the case on Android, but we cannot specify the exact type of biometrics (e.g. fingerprint or face scan). -+ * Whether the type is detected correctly depends on the platform and its native implementation. -+ * This should be treated as more of a hint. -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ returnUsedAuthenticationType?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -@@ -119,7 +184,7 @@ export declare function deleteItemAsync(key: string, options?: SecureStoreOption - * > After a key has been invalidated, it becomes impossible to read its value. - * > This only applies to values stored with `requireAuthentication` set to `true`. - */ --export declare function getItemAsync(key: string, options?: SecureStoreOptions): Promise; -+export declare function getItemAsync(key: string, options?: R | EmptyObject): Promise>; - /** - * Stores a key–value pair. - * -@@ -129,7 +194,7 @@ export declare function getItemAsync(key: string, options?: SecureStoreOptions): - * - * @return A promise that rejects if value cannot be stored on the device. - */ --export declare function setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise; -+export declare function setItemAsync(key: string, value: string, options?: R | EmptyObject): Promise>; - /** - * Stores a key–value pair synchronously. - * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when the `requireAuthentication` option is set to `true` until the user authenticates. -@@ -139,7 +204,7 @@ export declare function setItemAsync(key: string, value: string, options?: Secur - * @param options An [`SecureStoreOptions`](#securestoreoptions) object. - * - */ --export declare function setItem(key: string, value: string, options?: SecureStoreOptions): void; -+export declare function setItem(key: string, value: string, options?: R | EmptyObject): SecureStoreSetFeedback; - /** - * Synchronously reads the stored value associated with the provided key. - * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when reading a value with `requireAuthentication` -@@ -150,7 +215,7 @@ export declare function setItem(key: string, value: string, options?: SecureStor - * @return Previously stored value. It resolves with `null` if there is no entry - * for the given key or if the key has been invalidated. - */ --export declare function getItem(key: string, options?: SecureStoreOptions): string | null; -+export declare function getItem(key: string, options?: R | EmptyObject): SecureStoreGetFeedback; - /** - * Checks if the value can be saved with `requireAuthentication` option enabled. - * @return `true` if the device supports biometric authentication and the enrolled method is sufficiently secure. Otherwise, returns `false`. Always returns false on tvOS. -diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js -index e11bd94..940fc61 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.js -+++ b/node_modules/expo-secure-store/build/SecureStore.js -@@ -1,6 +1,53 @@ - import ExpoSecureStore from './ExpoSecureStore'; - import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; - // @needsAudit -+ -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5, -+}; -+// @needsAudit - /** - * The data in the keychain item cannot be accessed after a restart until the device has been - * unlocked once by the user. This may be useful if you need to access the item when the phone -@@ -102,7 +149,7 @@ export async function setItemAsync(key, value, options = {}) { - if (!isValidValue(value)) { - throw new Error(`Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`); - } -- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); -+ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); - } - /** - * Stores a key–value pair synchronously. -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 1268979..7764c8e 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -18,28 +18,36 @@ public final class SecureStoreModule: Module { - "WHEN_UNLOCKED_THIS_DEVICE_ONLY": SecureStoreAccessible.whenUnlockedThisDeviceOnly.rawValue - ]) - -- AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in -- return try get(with: key, options: options) -+ AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -+ let result = try get(with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .get, result: result, options: options).value - } - -- Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in -- return try get(with: key, options: options) -+ Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) in -+ let result = try get(with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .get, result: result, options: options).value - } - -- AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in -+ AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) in - guard let key = validate(for: key) else { - throw InvalidKeyException() - } - -- return try set(value: value, with: key, options: options) -+ let result = try set(value: value, with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .set, result: result, options: options).value - } - -- Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in -+ Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) in - guard let key = validate(for: key) else { - throw InvalidKeyException() - } - -- return try set(value: value, with: key, options: options) -+ let result = try set(value: value, with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .set, result: result, options: options).value - } - - AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -@@ -53,18 +61,7 @@ public final class SecureStoreModule: Module { - } - - Function("canUseBiometricAuthentication") {() -> Bool in -- #if os(tvOS) -- return false -- #else -- let context = LAContext() -- var error: NSError? -- let isBiometricsSupported: Bool = context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error) -- -- if error != nil { -- return false -- } -- return isBiometricsSupported -- #endif -+ return areBiometricsEnabled() - } - - Function("canUseDeviceCredentialsAuthentication") { () -> Bool in -@@ -76,6 +73,41 @@ public final class SecureStoreModule: Module { - return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthentication, error: nil) - } - -+ private func getAuthType() -> AuthType { -+ if !areBiometricsEnabled() {return AuthType.credentials} -+ let biometryType = LAContext().biometryType -+ -+ switch biometryType { -+ case .faceID: return .faceID -+ case .touchID: return .touchID -+ case .opticID: return .opticID // available since iOS 17 -+ case .none: fallthrough // this one continues to the next line -+ @unknown default: return .credentials -+ } -+ } -+ -+ private func wrapResultWithFeedback(action: SecureStoreFeedbackAction, result: T, options: SecureStoreOptions) -> any SecureStoreFeedback { -+ let authType = getAuthType().rawValue -+ -+ if (!options.returnUsedAuthenticationType) { -+ return SecureStoreOriginalFeedback(source: result, authType: authType) -+ } -+ -+ if (action == .get) { -+ return SecureStoreGetFeedback(source: result, authType: authType) -+ } -+ -+ return SecureStoreSetFeedback(source: result, authType: authType) -+ } -+ -+ private func areBiometricsEnabled() -> Bool { -+ #if os(tvOS) -+ return false -+ #else -+ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: nil) -+ #endif -+ } -+ - private func get(with key: String, options: SecureStoreOptions) throws -> String? { - guard let key = validate(for: key) else { - throw InvalidKeyException() -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index c9e843b..b95eca7 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -18,4 +18,71 @@ internal struct SecureStoreOptions: Record { - - @Field - var enableDeviceFallback: Bool = false -+ -+ @Field -+ var returnUsedAuthenticationType: Bool = false -+} -+ -+@available(iOS 11.2, *) -+public enum AuthType: Int, @unchecked Sendable { -+ /// The device does not support biometry. -+ case none = 0 -+ -+ /// The device supports device credentials -+ case credentials = 1 -+ -+ /// Generic type, not specified whether it was a faceID or touchID -+ case biometrics = 2 -+ -+ /// The device supports Face ID. -+ case faceID = 3 -+ -+ /// The device supports Touch ID. -+ case touchID = 4 -+ -+ /// The device supports Optic ID -+ case opticID = 5 -+} -+ -+public enum SecureStoreFeedbackAction: String { -+ case set -+ case get -+} -+ -+protocol SecureStoreFeedback { -+ associatedtype Source -+ associatedtype Value -+ var source: Source { get } -+ var authType: Int { get } -+ var value: Value { get } -+} -+ -+struct SecureStoreGetFeedback: SecureStoreFeedback { -+ typealias Source = T -+ typealias Value = Array -+ var source: Source -+ var authType: Int = AuthType.none.rawValue -+ var value: Value { -+ get { return [source, authType] } -+ } -+} -+ -+struct SecureStoreOriginalFeedback: SecureStoreFeedback { -+ typealias Source = T -+ typealias Value = T -+ var source: Source -+ var authType: Int = AuthType.none.rawValue -+ var value: Value { -+ get { return source } -+ } -+} -+ -+struct SecureStoreSetFeedback: SecureStoreFeedback { -+ typealias Source = T -+ typealias Value = Int -+ var source: Source -+ var authType: Int = AuthType.none.rawValue -+ var value: Value { -+ get { return authType } -+ } - } -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index 4f253a1..d44c0bb 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -1,8 +1,63 @@ - import ExpoSecureStore from './ExpoSecureStore'; - import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; - -+type EmptyObject = Record; -+type SecureStoreSetFeedback = -+ R['returnUsedAuthenticationType'] extends true ? AuthType : void; -+type SecureStoreGetFeedback< -+ T, -+ R extends SecureStoreOptions, -+> = R['returnUsedAuthenticationType'] extends true ? [T | null, AuthType] : T | null; - export type KeychainAccessibilityConstant = number; - -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5, -+} as const; -+ -+type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; -+ - // @needsAudit - /** - * The data in the keychain item cannot be accessed after a restart until the device has been -@@ -113,6 +168,23 @@ export type SecureStoreOptions = { - * @platform ios - */ - enableDeviceFallback?: boolean; -+ -+ /** -+ * When this flag is set to true, the get methods of SecureStore will return a two-element array. The first value will be the original value returned when this flag is set to false. -+ * The second value is the authentication type used to read the value from the AUTH_TYPE object. -+ * As for the set function, the returned value will simply be AUTH_TYPE. -+ * -+ * @warning -+ * If the iOS device supports biometrics and the user falls back to device credentials, it will not be detected. -+ * This is not the case on Android, but we cannot specify the exact type of biometrics (e.g. fingerprint or face scan). -+ * Whether the type is detected correctly depends on the platform and its native implementation. -+ * This should be treated as more of a hint. -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ returnUsedAuthenticationType?: boolean; - }; - - // @needsAudit -@@ -159,10 +231,10 @@ export async function deleteItemAsync( - * > After a key has been invalidated, it becomes impossible to read its value. - * > This only applies to values stored with `requireAuthentication` set to `true`. - */ --export async function getItemAsync( -+export async function getItemAsync( - key: string, -- options: SecureStoreOptions = {} --): Promise { -+ options: R | EmptyObject = {} -+): Promise> { - ensureValidKey(key); - return await ExpoSecureStore.getValueWithKeyAsync(key, options); - } -@@ -177,11 +249,11 @@ export async function getItemAsync( - * - * @return A promise that rejects if value cannot be stored on the device. - */ --export async function setItemAsync( -+export async function setItemAsync( - key: string, - value: string, -- options: SecureStoreOptions = {} --): Promise { -+ options: R | EmptyObject = {} -+): Promise> { - ensureValidKey(key); - if (!isValidValue(value)) { - throw new Error( -@@ -189,7 +261,7 @@ export async function setItemAsync( - ); - } - -- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); -+ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); - } - - /** -@@ -201,7 +273,11 @@ export async function setItemAsync( - * @param options An [`SecureStoreOptions`](#securestoreoptions) object. - * - */ --export function setItem(key: string, value: string, options: SecureStoreOptions = {}): void { -+export function setItem( -+ key: string, -+ value: string, -+ options: R | EmptyObject = {} -+): SecureStoreSetFeedback { - ensureValidKey(key); - if (!isValidValue(value)) { - throw new Error( -@@ -222,7 +298,10 @@ export function setItem(key: string, value: string, options: SecureStoreOptions - * @return Previously stored value. It resolves with `null` if there is no entry - * for the given key or if the key has been invalidated. - */ --export function getItem(key: string, options: SecureStoreOptions = {}): string | null { -+export function getItem( -+ key: string, -+ options: R | EmptyObject = {} -+): SecureStoreGetFeedback { - ensureValidKey(key); - return ExpoSecureStore.getValueWithKeySync(key, options); - } diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch deleted file mode 100644 index 59e74054b162..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch +++ /dev/null @@ -1,124 +0,0 @@ -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index 7097a8a..975d75b 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -153,6 +153,20 @@ export type SecureStoreOptions = { - * @platform ios - */ - returnUsedAuthenticationType?: boolean; -+ /** -+ * On iOS, the system does not ask for auth when saving the value to the SecureStore. -+ * The Android however, displays the authentication prompt when saving the value. -+ * To keep the behavior on every platform similar as much as possible, -+ * setting this flag to true will ensure that authentication is required when saving a value to the store. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore save method. -+ * It should only be considered an improvement to the user experience; it does not prevent the user -+ * from saving the value without authenticating using other methods (e.g. by directly modifying the keychain). -+ * For it to take effect, the 'requireAuthentication' flag must be set to true. -+ * @default false -+ * @platform ios -+ */ -+ forceAuthenticationOnSave?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 7764c8e..02f837c 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -35,6 +35,10 @@ public final class SecureStoreModule: Module { - throw InvalidKeyException() - } - -+ if options.requireAuthentication && options.forceAuthenticationOnSave { -+ try await triggerPolicy(options: options) -+ } -+ - let result = try set(value: value, with: key, options: options) - - return wrapResultWithFeedback(action: .set, result: result, options: options).value -@@ -190,6 +194,32 @@ public final class SecureStoreModule: Module { - } - } - -+ @MainActor -+ private func triggerPolicy(options: SecureStoreOptions) async throws { -+ let isPolicyAvailable = options.enableDeviceFallback ? areDeviceCredentialsEnabled() : areBiometricsEnabled() -+ -+ guard isPolicyAvailable else { -+ throw SecureStoreRuntimeError("No authentication method available") -+ } -+ -+ let localAuthPolicy: LAPolicy = options.enableDeviceFallback ? .deviceOwnerAuthentication : .deviceOwnerAuthenticationWithBiometrics -+ let localizedReason: String = options.authenticationPrompt ?? "Authentication required" -+ -+ let success: Bool = try await withCheckedThrowingContinuation { continuation in -+ LAContext().evaluatePolicy(localAuthPolicy, localizedReason: localizedReason) { success, error in -+ if let error = error { -+ continuation.resume(throwing: error) -+ } else { -+ continuation.resume(returning: success) -+ } -+ } -+ } -+ -+ guard success else { -+ throw SecureStoreRuntimeError("Unable to authenticate") -+ } -+ } -+ - private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? { - var query = query(with: key, options: options, requireAuthentication: requireAuthentication) - -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index b95eca7..321c7e5 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -21,6 +21,9 @@ internal struct SecureStoreOptions: Record { - - @Field - var returnUsedAuthenticationType: Bool = false -+ -+ @Field -+ var forceAuthenticationOnSave: Bool = false - } - - @available(iOS 11.2, *) -@@ -86,3 +89,10 @@ struct SecureStoreSetFeedback: SecureStoreFeedback { - get { return authType } - } - } -+ -+struct SecureStoreRuntimeError: LocalizedError { -+ let errorDescription: String? -+ init(_ description: String) { -+ self.errorDescription = description -+ } -+} -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index d44c0bb..c4b95ae 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -185,6 +185,21 @@ export type SecureStoreOptions = { - * @platform ios - */ - returnUsedAuthenticationType?: boolean; -+ -+ /** -+ * On iOS, the system does not ask for auth when saving the value to the SecureStore. -+ * The Android however, displays the authentication prompt when saving the value. -+ * To keep the behavior on every platform similar as much as possible, -+ * setting this flag to true will ensure that authentication is required when saving a value to the store. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore save method. -+ * It should only be considered an improvement to the user experience; it does not prevent the user -+ * from saving the value without authenticating using other methods (e.g. by directly modifying the keychain). -+ * For it to take effect, the 'requireAuthentication' flag must be set to true. -+ * @default false -+ * @platform ios -+ */ -+ forceAuthenticationOnSave?: boolean; - }; - - // @needsAudit diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch deleted file mode 100644 index c3acba54c616..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch +++ /dev/null @@ -1,100 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index d209efc..e362aef 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -212,6 +212,10 @@ open class SecureStoreModule : Module() { - return SecureStoreOriginalFeedback(null) - } - -+ if (prefs.contains(keychainAwareKey) && options.failOnUpdate) { -+ throw WriteException("Key already exists", key, options.keychainService) -+ } -+ - try { - if (keyIsInvalidated) { - // Invalidated keys will block writing even though it's not possible to re-validate them -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 4e30440..16674b8 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -11,7 +11,8 @@ class SecureStoreOptions( - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, - @Field var requireAuthentication: Boolean = false, - @Field var enableDeviceFallback: Boolean = false, -- @Field var returnUsedAuthenticationType: Boolean = false -+ @Field var returnUsedAuthenticationType: Boolean = false, -+ @Field var failOnUpdate: Boolean = false - ) : Record, Serializable - - enum class SecureStoreAuthType(index: Int) { -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index f1e3610..8954c59 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -167,6 +167,16 @@ export type SecureStoreOptions = { - * @platform ios - */ - forceAuthenticationOnSave?: boolean; -+ /** -+ * If the key has already been stored, the save function will throw an error instead of overwriting it. -+ * The behaviour differs slightly depending on the platform. -+ * On Android, an error is thrown before the authentication prompt. On iOS, an error is thrown after authentication. -+ * -+ * @default false -+ * @platform ios -+ * @platform android -+ */ -+ failOnUpdate?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 02f837c..47282db 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -169,6 +169,9 @@ public final class SecureStoreModule: Module { - SecItemDelete(query(with: key, options: options, requireAuthentication: !options.requireAuthentication) as CFDictionary) - return true - case errSecDuplicateItem: -+ if options.failOnUpdate { -+ throw SecureStoreRuntimeError("Key already exists") -+ } - return try update(value: value, with: key, options: options) - default: - throw KeyChainException(status) -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 321c7e5..6a787b0 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -24,6 +24,9 @@ internal struct SecureStoreOptions: Record { - - @Field - var forceAuthenticationOnSave: Bool = false -+ -+ @Field -+ var failOnUpdate: Bool = false - } - - @available(iOS 11.2, *) -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index c4b95ae..0bedcaa 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -200,6 +200,17 @@ export type SecureStoreOptions = { - * @platform ios - */ - forceAuthenticationOnSave?: boolean; -+ -+ /** -+ * If the key has already been stored, the save function will throw an error instead of overwriting it. -+ * The behaviour differs slightly depending on the platform. -+ * On Android, an error is thrown before the authentication prompt. On iOS, an error is thrown after authentication. -+ * -+ * @default false -+ * @platform ios -+ * @platform android -+ */ -+ failOnUpdate?: boolean; - }; - - // @needsAudit diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch deleted file mode 100644 index f65e0a31540a..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch +++ /dev/null @@ -1,77 +0,0 @@ -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index 836958c..29531c5 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -177,6 +177,18 @@ export type SecureStoreOptions = { - * @platform android - */ - failOnUpdate?: boolean; -+ /** -+ * The LocalAuthentication behaves slightly differently on iOS simulators. -+ * In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). -+ * Setting this flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. -+ * This is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. -+ * This has no effect on real devices. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore read method. -+ * @default false -+ * @platform ios -+ */ -+ forceReadAuthenticationOnSimulators?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 47282db..dcbe242 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -19,6 +19,11 @@ public final class SecureStoreModule: Module { - ]) - - AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -+ #if targetEnvironment(simulator) -+ if options.requireAuthentication && options.forceReadAuthenticationOnSimulators { -+ try await triggerPolicy(options: options) -+ } -+ #endif - let result = try get(with: key, options: options) - - return wrapResultWithFeedback(action: .get, result: result, options: options).value -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 6a787b0..8f110dd 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -27,6 +27,9 @@ internal struct SecureStoreOptions: Record { - - @Field - var failOnUpdate: Bool = false -+ -+ @Field -+ var forceReadAuthenticationOnSimulators: Bool = false - } - - @available(iOS 11.2, *) -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index 0bedcaa..bae5580 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -211,6 +211,19 @@ export type SecureStoreOptions = { - * @platform android - */ - failOnUpdate?: boolean; -+ -+ /** -+ * The LocalAuthentication behaves slightly differently on iOS simulators. -+ * In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). -+ * Setting this flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. -+ * This is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. -+ * This has no effect on real devices. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore read method. -+ * @default false -+ * @platform ios -+ */ -+ forceReadAuthenticationOnSimulators?: boolean; - }; - - // @needsAudit From 35d98a01491a260c912bf7bf708b36fdb46b2765 Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Thu, 9 Apr 2026 19:25:07 +0000 Subject: [PATCH 8/8] Remove stale files from bad merge history The branch carried old versions of files (MultifactorAuthentication, FlagCommentPage, ReimbursementAccount, ConnectToQuickbooksDesktopFlow, etc.) that were introduced by a prior merge but no longer exist on main. These caused typecheck, ESLint, and test failures. Co-authored-by: Daniel Gale-Rosen --- assets/images/camera-flip.svg | 1 - .../Assign-and-Manage-Cards.md | 46 -- .../connect-credit-cards/Commercial-feeds.md | 109 ----- .../Company-Card-Settings.md | 97 ---- .../connect-credit-cards/Direct-feeds.md | 141 ------ .../connect-credit-cards/Personal-Cards.md | 137 ------ .../AttachmentPicker/AttachmentCamera.tsx | 276 ----------- .../index.native.tsx | 15 - .../biometrics/useNativeBiometrics.ts | 202 --------- .../DateSelectPopup/ActionButtons.tsx | 41 -- .../NativeBiometrics/ED25519/index.ts | 157 ------- .../NativeBiometrics/KeyStore.ts | 122 ----- .../NativeBiometrics/SecureStore/index.ts | 75 --- .../NativeBiometrics/SecureStore/index.web.ts | 64 --- .../NativeBiometrics/SecureStore/types.ts | 62 --- .../NativeBiometrics/VALUES.ts | 56 --- .../NativeBiometrics/helpers.ts | 32 -- .../NativeBiometrics/types.ts | 49 -- src/pages/FlagCommentPage.tsx | 170 ------- .../USD/USDVerifiedBankAccountFlow.tsx | 97 ---- .../ExpensifyCardPage/ChangePINATMPage.tsx | 23 - .../Wallet/PersonalCards/AddNewCardPage.tsx | 92 ---- .../ReconciliationAccountSettingsPage.tsx | 120 ----- .../xero/advanced/XeroAutoSyncPage.tsx | 98 ---- .../MultifactorAuthentication/ED25519.test.ts | 75 --- .../SecureStore.test.ts | 21 - .../useNativeBiometrics.test.ts | 428 ------------------ .../NativeBiometrics/KeyStore.test.ts | 162 ------- .../NativeBiometrics/helpers.test.ts | 61 --- 29 files changed, 3029 deletions(-) delete mode 100644 assets/images/camera-flip.svg delete mode 100644 docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md delete mode 100644 docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md delete mode 100644 docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md delete mode 100644 docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md delete mode 100644 docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md delete mode 100644 src/components/AttachmentPicker/AttachmentCamera.tsx delete mode 100644 src/components/ConnectToQuickbooksDesktopFlow/index.native.tsx delete mode 100644 src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts delete mode 100644 src/components/Search/FilterDropdowns/DateSelectPopup/ActionButtons.tsx delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/ED25519/index.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.web.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/types.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/helpers.ts delete mode 100644 src/libs/MultifactorAuthentication/NativeBiometrics/types.ts delete mode 100644 src/pages/FlagCommentPage.tsx delete mode 100644 src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlow.tsx delete mode 100644 src/pages/settings/Wallet/ExpensifyCardPage/ChangePINATMPage.tsx delete mode 100644 src/pages/settings/Wallet/PersonalCards/AddNewCardPage.tsx delete mode 100644 src/pages/workspace/accounting/reconciliation/ReconciliationAccountSettingsPage.tsx delete mode 100644 src/pages/workspace/accounting/xero/advanced/XeroAutoSyncPage.tsx delete mode 100644 tests/unit/MultifactorAuthentication/ED25519.test.ts delete mode 100644 tests/unit/MultifactorAuthentication/SecureStore.test.ts delete mode 100644 tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts delete mode 100644 tests/unit/libs/MultifactorAuthentication/NativeBiometrics/KeyStore.test.ts delete mode 100644 tests/unit/libs/MultifactorAuthentication/NativeBiometrics/helpers.test.ts diff --git a/assets/images/camera-flip.svg b/assets/images/camera-flip.svg deleted file mode 100644 index 6d05251e0c77..000000000000 --- a/assets/images/camera-flip.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md deleted file mode 100644 index 6c2194f4931f..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Assign and Manage Corporate Cards -description: Learn how to assign corporate cards to your employees and manage assigned corporate cards in Expensify. -keywords: [New Expensify, corporate cards, bulk assignment, card feed, workspace cards, card management, card assignment, admin tools] ---- - -
- -Admins can assign and manage all corporate cards — both assigned and unassigned — from a single streamlined view in New Expensify. Updating in bulk makes it easier and faster to assign cards, especially when managing dozens or hundreds at once. - - -# Assign Company Cards - -1. Go to your workspaces. -2. Select **[Workspace Name] > Company cards**. -3. If you have multiple card feeds in your workspace, click the feed name at the top left to select the correct one. -4. Click **Assign card** next to the card you want to assign. You can filter by card number or name above the card list to make it easier to find a specific card. -5. Select which employee you want to assign the card to. - - **Note:** If you're assigning a card to a new employee, or one without an Expensify account, you can invite them during card assignment. Please note that this may result in an increase in your bill depending on the number of available seats you have. -6. Review the details to ensure it looks correct. - - **Note:** You can choose to either import cards using a custom transaction start date or from the beginning, which imports transactions as far back as available. Most company card feeds can import 30-90 days of historical transactions, depending on the bank. -7. Click **Confirm** to finalize the assignment. - -# Manage Assigned Cards - -After assigning cards to users, you can manage those assigned cards from the same page. To find a card: -- Filter cards to assigned cards, unassigned cards, or all cards. -- Search by employee name/email, last 4 digits of the card, or custom card name. -- Sort columns for faster organization and review. - -**Available actions:** -- Rename the card -- Assign a specific export account -- Update transactions (to manually import newer transactions) -- Unassign the card - -**Note:** Unassigning a card will delete unsubmitted expenses from draft reports for that cardholder. - - -# FAQ - -## What happens if a card fails to assign? - -You’ll see an error message directly in the card row. This allows you to quickly resolve the issue without leaving the page. - -
diff --git a/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md b/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md deleted file mode 100644 index a9afddab0c53..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Commercial Card Feeds -description: Learn how to set up and manage commercial card feeds (Visa, Mastercard, Amex) in Expensify. -keywords: [New Expensify, commercial feed, Mastercard feed, Visa feed, Amex feed, company cards, corporate cards, CDF, VCF, GL1025, control account] ---- - -Commercial feeds are the most reliable way to import company card expenses. These feeds are not affected by login credential changes or banking UI updates, making them ideal for growing teams and finance admins. - ---- -# Commercial Card Feeds - -Commercial card feeds are file-based connections provided directly by your bank. Unlike direct feeds, which require you to log in with your bank credentials, commercial feeds are managed by your bank and typically used for large-scale corporate card programs. Contact your bank directly to check if your cards are eligible for a commercial feed. - -## Who can use Commercial Card Feeds - -- **Collect plan** members can add one commercial card feed. -- **Control plan** members can add an unlimited number of company card feeds. -- Workspace creation is required before connecting cards. - ---- - -# Set up a Mastercard Commercial Feed - -To use a Mastercard commercial feed, your bank must send your transaction data from **Mastercard’s SmartData portal**. - -1. Ask your bank to deliver your **CDF (Common Data File)** to Expensify via SmartData. - - File type must be: **CDF v3 Release 11.01** - - Specify the earliest transaction date needed. -2. Your bank will send you a **Distribution ID**. -3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. -4. Choose **Commercial Feed**, then select **Mastercard Commercial Cards**. -5. Enter the **Distribution ID** and submit. - -Expensify will notify you when the feed is live. - ---- - - -# Set up a Visa Commercial Feed - -Visa commercial feeds are configured through your bank using Visa’s **Subscription Management portal**. - -1. Ask your bank’s relationship manager to enable your **VCF (Visa Commercial Format)** feed and send it to **Expensify**. - - Mention: “You can check a box in Visa’s Subscription Management portal to enable feed delivery—no test file needed.” -2. Request the feed details: - - **Processor ID** - - **Bank (Financial Institution) ID** - - **Company ID** -3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. -4. Choose **Commercial Feed**, then select **Visa Commercial Cards**. -5. Enter the required IDs and submit. - -Expensify will notify you when the feed is connected. - ---- - -# Set up an American Express Corporate Card Feed - -To set up an Amex Corporate Feed, you’ll need to complete and email their required forms. - -1. Download the forms: - [Amex Feed Setup Forms (Google Drive)](https://drive.google.com/file/d/1zqDA_MCk06jk_fWjzx2y0r4gOyAMqKJe/view?usp=sharing) -2. Complete the form as follows: - - **Page 1:** Fill in legal company details, requestor name/email, feed start date, and **Control Account Number** (not a credit card number). - - **Page 2:** Leave blank. - - **Page 3:** Re-enter client name and Control Account. - - **Page 4:** Add country, authorization name, title, and signature (DD/MM/YY format). -3. Email the completed forms to: **electronictransmissionsteam@aexp.com** -4. Wait for the **Production Letter** containing your feed’s file name. -5. 3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. -4. Choose **Commercial Feed**, then select **American Express Corporate Cards**. -7. You’ll be notified once the feed is live. - ---- - -## Manage commercial card settings - -You can update individual cards under **Company Cards > [Card Name]**. - -### Available actions: -- **Rename the card** for easier tracking. -- **Assign export account** for accounting integration. -- **Update transactions** to pull the latest expenses. -- **Unassign the card** to remove it from a user and delete unsubmitted drafts. - -![Manage the card on the card details page]({{site.url}}/assets/images/commfeed/commfeed-07-updated.png){:width="100%"} - ---- - -# FAQ - -## What’s the difference between a commercial feed and a direct feed? - -- **Direct feeds** connect using your bank login credentials. They’re quick to set up but may require periodic re-authentication. -- **Commercial feeds** are bank-managed file connections. They require setup through your bank but offer a **more stable and uninterrupted** connection. - -## My feed is connected, but I don’t see my card. What now? - -Cards will only appear if: -- They are active -- At least one transaction has posted - -If a card still isn’t showing, contact your account manager or email [concierge@expensify.com](mailto:concierge@expensify.com). - -## Is there an extra cost for commercial feeds? - -No. Commercial feeds are included in the Collect and Control plans: -- **Collect** includes one commercial feed. -- **Control** supports unlimited feeds. diff --git a/docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md b/docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md deleted file mode 100644 index 7ebd35852cf3..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Company Card Settings -description: Learn how to manage third-party company card feeds in Expensify, including commercial and direct feeds, accounting exports, and automated eReceipts. -keywords: [New Expensify, company cards, card feed settings, third-party cards, commercial feeds, direct feeds, accounting exports, ereceipts, expense automation, card management] -internalScope: Applies to Workspace Admins. Covers how to manage third-party feeds on the workspace level. Does not cover the Expensify Card outside of plan availability. ---- - -# Company Card Settings - -Workspace Admins can manage company card settings, export logic, and eReceipt functionality at the workspace level. This guide outlines how to configure your company card connection, route expenses to accounting systems, and automate receipt handling. - -To set up a direct company credit card feed on a Workspace, see [Direct Feeds](/articles/new-expensify/connect-credit-cards/Direct-feeds)). - -To set up a commercial card file feed on a Workspace, see [Commercial Feeds](https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds). - ---- - -## Manage company card settings for company card feeds - -To update your Company Card feed settings: - -1. In the navigation tabs (on the left on web, on the bottom on mobile) navigate to **Workspaces > [Workspace Name]**. -2. Within the Workspace, select **Company Cards** and chose the relevant company card feed. -3. Click **Settings** in the top right corner. - -You can manage the following options: - -- **Card feed name** - Update the name of the card feed to help identify it. - -- **Allow deleting transactions** - Enable this setting to allow cardholders to delete card transactions. - - **Note:** This setting only applies to transactions imported after the setting is enabled. - -- **Remove card feed** - Remove the card feed from the workspace and unassign all cards. If the feed is not connected to another workspace, it is permanently deleted. - - **Note:** Removing the feed or unassigning a card deletes all imported expenses in the **Unreported** and **Draft** states. - -![Tap settings to open the card feed settings page]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_12.png){:width="100%"} - ---- - -## How to export transactions to your accounting system - -If you’ve connected accounting software (like **QuickBooks**, **NetSuite**, or **Xero**), you can export company card expenses either to a **central account** or **individual accounts**. - -## How to export to a central account - -To apply this setting to all card expenses in the workspace: - -1. Go to **Workspaces > [Workspace Name] > Accounting > Connections > Export**. -2. Under **Export company card expenses as**, choose **Central account**. - -## How to export to individual accounts - -To override the central export account per card: - -1. Go to **Workspaces > [Workspace Name] > Company Cards**. -2. Click on an assigned card to open the **Card Details** page. -3. Select a specific **export account** for that card. - -**Note:** If the card is set to Default Card, the central export account will be used. - ---- - -## How to use eReceipts with third-party card Feeds - -**eReceipts** are digital receipts that replace paper ones for many USD transactions of $75 or less on commercial and direct card feeds. - -1. Go to **Workspaces > [Workspace Name] > More Features** and toggle on **Rules**. -2. Navigate to the **Rules** section. -3. Enable **eReceipts**. - -**Things to Note:** -- eReceipts are not generated for certain categories, like **lodging**. -- Missing or incorrect category data may prevent eReceipts from being applied. -- Manually re-categorizing an expense can invalidate an existing eReceipt. - ---- - -# FAQ - -## Are commercial and direct card feeds limited by plan? - -Yes, below is a breakdown: -- On the **Collect plan**, you can connect one [commercial](https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds) or [direct feed](https://help.expensify.com/articles/new-expensify/connect-credit-cards/Direct-feeds). -- The **Control plan** supports **unlimited** card connections. -- The [Expensify Card](https://use.expensify.com/company-credit-card) is available on **both plans**. - -## When do direct or commercial feed transactions import? - -Transactions import **after a card is assigned**. Once a purchase **posts** (typically within 1–3 business days), it will appear in the cardholder’s account. - -**Note:** Pending transactions are not imported. - diff --git a/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md b/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md deleted file mode 100644 index bc95dc03ce43..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: Direct Company Card Feeds -description: Learn how to connect company credit cards using direct feeds or Plaid to import transactions and assign cards in New Expensify. -keywords: [New Expensify, direct feeds, commercial cards, Plaid integration, company cards, credit card import, assign cards, connect bank, manage card feeds, corporate cards, Amex Small Business] ---- - - -Direct feeds in Expensify provide a fast, reliable way to import company card transactions. You can also use Plaid to connect your company card program, giving you even more options to manage your corporate card account directly in Expensify. You can connect eligible commercial cards using a direct feed. - -You can add one **direct** or [**commercial feed**](https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds) on the **Collect plan**. Upgrade to the **Control plan** to add unlimited company cards. - ---- - -# Prerequisites - -**A workspace on the Collect or Control plan:** Before setting up a direct feed, go to **Workspaces > New Workspace** to create a workspace (if you haven't already). - ---- - -# Set Up a Direct Feed - -1. . Go to **Workspaces > [Workspace Name] > Company cards** to view your company’s card setup page. -2. Click **Add cards** to start connecting a new feed. -3. **Select your country** from the list. - - Expensify will prefill this based on your workspace’s currency, but you can manually change it if needed. - - You won’t be able to continue without selecting a country -- You'll see an error. - -4. **Choose your feed type** when prompted: - - **Direct feed** – This is the default option in most supported countries and works for the majority of users. - - **Commercial feed** – Best for large-scale card programs where your bank facilitates the connection directly. -5. **Pick your bank:** - - If you’re in the U.S., select your bank from the list or choose **Other** to see additional banks supported by Plaid. - - For international members in supported countries, choosing **Direct feed** will automatically launch the Plaid connection process. - -![Click add cards to add a card feed]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_01.png){:width="100%"} - ---- - -# Assign Company Cards - -Once connected, you'll want to assign the company cards to their respective cardholders: - -1. Go to **Workspaces > [Workspace Name] > Company cards**. -2. If you have multiple feeds, click the feed name at the top left to select the correct one. -3. Click **Assign card** - - You may be prompted to reauthenticate your account before assigning cards. -5. Select a **workspace member** from the list. -6. Choose a **card** (only cards with recent transactions will appear). -7. Set a **start date**: - - **From the beginning** (typically 30–90 days) - - **Custom date** to define your own range -8. Review your selections and click **Assign card**. - -![Click the feed name in the top left to open the feed selector where you can select a feed from the list]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_06.png){:width="100%"} - -![Click company cards in the workspace editor to open the feed]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_05.png){:width="100%"} - ---- - -# Manage Assigned Cards - -To manage a card, click any assigned card to open the **Card details** page. - -![Tap the assigned card to open the card details page where you can manage the card]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_11.png){:width="100%"} - -**Available actions:** -- Rename the card -- Assign a specific export account -- Update transactions -- Unassign the card - -**Note:** Unassigning a card will delete unsubmitted expenses from draft reports for that cardholder. - ---- - -# FAQ - -## What if I’m outside the U.S.? - -Plaid feeds are available in many countries outside the U.S. If your country isn’t supported, you’ll see the option to connect a **Commercial feed**. - -**Direct feeds are supported for the following countries:** -- United States -- Canada -- United Kingdom -- Austria -- Belgium -- Denmark -- Estonia -- Finland -- France -- Germany -- Ireland -- Italy -- Latvia -- Lithuania -- Netherlands -- Norway -- Poland -- Portugal -- Spain -- Sweden - -## How do I reconnect a broken feed? - -1. Go to **Workspaces > [Workspace Name] > Company cards** -2. Click the red error banner -3. Log in to your bank using your bank account credentials to restore the connection - -## Can I connect multiple feeds from the same bank? - -Not quite. Each workspace can only have **one direct feed per bank**. -If your company uses cards from multiple banks, that’s no problem — just connect each bank as its own separate feed. - -## Is there an extra cost? - -Nope — there's no extra charge for using Plaid or direct feeds. - -- With the **Collect** plan, you can add one direct or Plaid feed at no cost. -- To connect more than one, you'll need to upgrade to the **Control** plan. - -## Can I import debit card transactions? - -Yes! Debit card transactions can be imported by linking a checking/current account in any Plaid-supported country. - -This is especially helpful in the EU, where open banking rules often block access to credit card data via aggregators. Debit cards, however, are fully supported under these regulations. - -## Which banks support direct feeds? - -Below are examples of banks that support direct feeds. This is not a complete list: - -- American Express -- Bank of America -- Brex -- Capital One -- Chase -- Citibank -- Stripe -- Wells Fargo -- Plaid enables connections with hundreds of additional banks across the U.S., Canada, and Europe. Check the [Plaid coverage page for US/Canada](https://plaid.com/docs/institutions/) or [Plaid coverage page for UK/Europe](https://plaid.com/docs/institutions/europe/) to see if your bank is supported. - diff --git a/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md deleted file mode 100644 index 91f6dcef01e5..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: Manage personal cards in New Expensify -description: Learn how to view, manage, and fix broken connections for personal credit cards in New Expensify. -keywords: [New Expensify, personal cards, Wallet, reimbursable, credit card import, Expensify Classic, New Expensify, assigned cards, card details, broken connection, fix card] -internalScope: Audience is members with personal credit cards already connected to their account. Covers viewing and managing existing personal cards in the Wallet, including reimbursable settings and fixing broken card connections. Does not cover connecting new personal cards or company card programs. ---- - -# Manage personal cards in New Expensify - -Expensify lets you view and manage your personal credit cards in one place, making it easier to track spending, submit reimbursable expenses, and stay organized for tax time. - -If you previously connected a personal card in Expensify Classic, you can now manage that card directly in Expensify on both web and mobile. - ---- - -## Who can manage personal cards - -You can manage personal cards if you have a personal credit card that was already connected to your account. You can manage these cards on both web and mobile, alongside any company cards on your account. - ---- - -## How to view personal cards in Wallet - -1. Click the navigation tabs (on the left on web, on the bottom on mobile), then select **Account > Wallet**. -2. Under **Assigned cards**: - - Personal cards imported from Expensify Classic will appear here. - - You'll see the card name, bank icon, and last 4 digits. -3. Tap the personal card you want to manage. - -If you have both company and personal cards, you'll see them separated into **Company cards** and **Personal cards** sections. - -![Tap a personal card to open card details]({{site.url}}/assets/images/personal-card-01.png){:width="100%"} - ---- - -## What you can do with personal cards - -You can: -- View all personal cards imported from Expensify Classic -- Automatically import transactions from your linked personal cards -- Generate IRS-compliant eReceipts for eligible USD purchases -- Update personal card settings -- Fix broken card connections -- Filter expenses by card in the **Expenses** tab on the **Reports** page - ---- - -## How to update personal card settings - -1. Click the navigation tabs (on the left on web, on the bottom on mobile), then select **Account > Wallet**. -2. Under **Assigned cards**, select a personal card. -3. On the **Card details** page, you can: - - Rename the card - - Update the card to pull in new transactions (if not CSV-imported) - - Unassign the card if it's no longer needed - - Toggle **Mark transactions as reimbursable** - - The reimbursable setting applies only to **new** transactions and is turned on by default. - -**Note:** Unassigning a personal card permanently deletes any unreported expenses or expenses on draft reports from that card. - -![Update personal card settings from the card details page]({{site.url}}/assets/images/personal-card-02.png){:width="100%"} - ---- - -## Why a personal card connection can break - -A personal card connection can break when your bank can no longer authenticate your account. Common causes include: - -- Your bank login credentials changed (e.g., you updated your password). -- Your bank requires additional verification (e.g., multi-factor authentication expired). -- Your bank revoked access to the third-party connection. -- A temporary outage at your bank disrupted the connection. - -When a connection breaks, Expensify can no longer import new transactions from that card. - ---- - -## How to know when a personal card connection is broken - -When a personal card connection breaks, you'll see: - -- A notification in the **Time Sensitive** section on **Home**. -- A red dot indicator on **Wallet** in the navigation tabs. -- A red dot indicator on the card in **Wallet** leading to the error. - - - - -## How to fix a broken personal card connection - -1. In the navigation tabs (on the left on web, on the bottom on mobile) navigate to **Account > Wallet**. -2. Under **Assigned cards**, select the card with the broken connection. -3. Choose **Fix card**. -4. Log into your bank when prompted to re-authenticate the connection. - -If logging into your bank doesn't resolve the issue, tap **Update card** to attempt a manual refresh. - ---- - -## How the broken connection violation works - -When you have a broken personal card connection, Expensify adds a **broken connection** violation to receipt-scanned cash expenses across all of your workspaces. This violation holds those expenses back in case they need to be merged with card transactions once the connection is fixed. - -To resolve the violation, fix the broken card connection by following the steps above. The violation is automatically removed from affected expenses once the connection is restored. - -You can tap the hyperlinked text in the violation to go directly to the **Card details** page in **Wallet** where you can fix the connection. - ---- - -# FAQ - -## Can I add another personal card? - -Not yet. Only cards connected to your account in Expensify Classic are available to manage in New Expensify. Support for adding new personal cards will be available in a future update. - -## Can I change the reimbursable setting for past transactions? - -No. Changes to the reimbursable setting only apply to transactions imported after the change. - -## Why don't I see my personal card in the Wallet? - -Personal cards appear in the Wallet only if they were previously connected to your account in Expensify Classic. If your card doesn't show up, it means it wasn't connected before and isn't available to manage yet. - -## Why do I see a broken connection violation on my expenses? - -When a personal card connection breaks, Expensify adds a broken connection violation to receipt-scanned cash expenses. This holds them back in case they need to merge with card transactions once the connection is restored. Fix the card connection to remove the violation automatically. - -## When is the broken connection error removed? - -The broken connection error is automatically removed when any of the following occurs: - -- Logging into your bank successfully re-authenticates the connection. -- Tapping Update card successfully refreshes the card. -- Expensify's daily automatic card refresh succeeds. diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx deleted file mode 100644 index 7f0b5a7f4db9..000000000000 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Alert, AppState, Modal, View} from 'react-native'; -import {RESULTS} from 'react-native-permissions'; -import type {Camera, PhotoFile} from 'react-native-vision-camera'; -import {useCameraDevice, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; -import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/Button'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Icon from '@components/Icon'; -import ImageSVG from '@components/ImageSVG'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; -import getPhotoSource from '@libs/fileDownload/getPhotoSource'; -import Log from '@libs/Log'; -import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; -import CONST from '@src/CONST'; - -type CapturedPhoto = { - uri: string; - fileName: string; - type: string; - width: number; - height: number; -}; - -type AttachmentCameraProps = { - /** Whether the camera modal is visible */ - isVisible: boolean; - - /** Callback when a photo is captured */ - onCapture: (photos: CapturedPhoto[]) => void; - - /** Callback when the camera is closed without capturing */ - onClose: () => void; -}; - -function AttachmentCamera({isVisible, onCapture, onClose}: AttachmentCameraProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const insets = useSafeAreaInsets(); - - const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip']); - const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); - - const camera = useRef(null); - const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); - const isCapturing = useRef(false); - const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); - - const device = useCameraDevice(cameraPosition, { - physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], - }); - const format = useCameraFormat(device, [{photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, {photoResolution: 'max'}]); - const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined; - const hasFlash = !!device?.hasFlash; - - // Check camera permissions when modal opens and refresh when app returns to foreground - useEffect(() => { - if (!isVisible) { - return; - } - - const refreshCameraPermissionStatus = () => { - CameraPermission.getCameraPermissionStatus?.() - .then(setCameraPermissionStatus) - .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); - }; - - // Initial permission check — request if not yet asked - CameraPermission.getCameraPermissionStatus?.() - .then((status) => { - if (status === RESULTS.DENIED) { - return CameraPermission.requestCameraPermission?.().then(setCameraPermissionStatus); - } - setCameraPermissionStatus(status); - }) - .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); - - // Refresh permission when the app returns to foreground (e.g. after granting in OS Settings) - const subscription = AppState.addEventListener('change', (appState) => { - if (appState !== 'active') { - return; - } - refreshCameraPermissionStatus(); - }); - - return () => { - subscription.remove(); - }; - }, [isVisible]); - - const [flash, setFlash] = useState(false); - - const askForPermissions = useCallback(() => { - // There's no way we can check for the BLOCKED status without requesting the permission first - // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 - CameraPermission.requestCameraPermission?.() - .then((status: string) => { - setCameraPermissionStatus(status); - if (status === RESULTS.BLOCKED) { - showCameraPermissionsAlert(translate); - } - }) - .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); - }, [translate]); - - const capturePhoto = useCallback(() => { - // Check permissions first — camera ref will be null when permission is not granted - // because the VisionCamera component is not rendered - if (!camera.current && (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED)) { - askForPermissions(); - return; - } - - if (!camera.current || isCapturing.current) { - return; - } - - isCapturing.current = true; - - camera.current - .takePhoto({ - flash: flash && hasFlash ? 'on' : 'off', - }) - .then((photo: PhotoFile) => { - const uri = getPhotoSource(photo.path); - const fileName = photo.path.split('/').pop() ?? `photo_${Date.now()}.jpg`; - - onCapture([ - { - uri, - fileName, - type: 'image/jpeg', - width: photo.width, - height: photo.height, - }, - ]); - }) - .catch((error: unknown) => { - Log.warn('AttachmentCamera: Error taking photo', {error}); - Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); - }) - .finally(() => { - isCapturing.current = false; - }); - }, [cameraPermissionStatus, flash, hasFlash, onCapture, translate, askForPermissions]); - - return ( - - - - {/* Camera viewfinder area */} - - {cameraPermissionStatus !== RESULTS.GRANTED && ( - - - {translate('receipt.takePhoto')} - {translate('receipt.cameraAccess')} -