diff --git a/docs/test-architecture.md b/docs/test-architecture.md index 5ff1b1bbd..972220469 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -124,6 +124,11 @@ run does not prove for each one; the taxonomy and cross-repository entries live A green run proves the quote list, pending-table and stats-chart fixtures render. It does not prove that the API returns those payloads, that login or token verification works, or that those staff/settings or stats endpoints return real data. +- **The RealUnit compliance visual spec answers the customer list and dossier itself.** + `e2e/realunit-compliance.spec.ts` fulfils `GET /v1/realunit/compliance/customers` and + `GET /v1/realunit/compliance/customers/:id` with synthetic fixtures (including `addresses`). + A green run proves those fixtures render. It does not prove that the API returns that payload + or that the server filters to RealUnit wallets. - **Two specs force KYC completeness.** Both collection-invoice cases — the refused QR and the stored-detail error — override `**/v2/user` so that `kyc.dataComplete` is read as `true`, because the invoice button is gated on that value. A green run therefore proves nothing about the gate for @@ -168,6 +173,22 @@ run does not prove for each one; the taxonomy and cross-repository entries live `e2e-stack/specs/buy.spec.ts` (`openQuoteCapableBuy` and older quote cases) updates the limit directly so `LIMIT_EXCEEDED` does not hide payment info. A green run does **not** prove that a customer reaches that limit through the product path. +- **Full-stack continue-race specs SQL-write `user_data.tradeApprovalDate`.** + `e2e-stack/specs/kyc-continue-race.spec.ts` sets the date so recommendation is skipped. A green + run does **not** prove that a customer obtains trade approval through the product path. +- **Full-stack continue-race specs SQL-insert STRICT `TfaLog` rows.** + `e2e-stack/specs/kyc-continue-race.spec.ts` inserts `kyc_log` type `TfaLog` with comment + `Strict (App)` so `continue()` does not 403 after FinancialData starts. A green run does + **not** prove the mail/app 2FA enrolment or verification path. +- **Full-stack continue-race specs recreate `kyc_step` unique index `NULLS NOT DISTINCT`.** + `e2e-stack/specs/kyc-continue-race.spec.ts` drops the synchronize unique index on + `(userDataId, name, type, sequenceNumber)` and creates `IDX_3a1150791476264753a67212a1` + with `NULLS NOT DISTINCT`, matching production. A green run does **not** prove the + migration chain applied that index. +- **Full-stack continue-race specs SQL-complete KYC steps.** + `e2e-stack/specs/kyc-continue-race.spec.ts` upserts ContactData, PersonalData, NationalityData + and Ident (`SumsubAuto`) to `Completed`. A green run does **not** prove those steps complete + through the product path, including live ident. - **The settings verification-call visual spec answers GET /v2/user itself.** `e2e/settings-verification-call.spec.ts` fulfils `/v2/user` with three synthetic kyc payloads (`phoneCallAccepted` unset / true / false) and fulfils the Settings bootstrap GETs diff --git a/e2e-stack/specs/kyc-continue-race.spec.ts b/e2e-stack/specs/kyc-continue-race.spec.ts new file mode 100644 index 000000000..f5aca8186 --- /dev/null +++ b/e2e-stack/specs/kyc-continue-race.spec.ts @@ -0,0 +1,236 @@ +/** + * Proves the ident-complete continue race against the real API and Postgres. + * + * Production failure: after Sumsub ident finished, the client fired many overlapping + * PUT /v2/kyc calls; one FinancialData insert won and the rest hit the unique index. + * + * Lowest layer that can express that: 13 parallel continues against a live API and + * Postgres. FinancialData has a NULL type, so the unique index only conflicts when it is + * NULLS NOT DISTINCT (production). The harness schema comes from synchronize, so this file + * recreates that index before the burst. All HTTP 200 plus COUNT=1 would also pass on the + * old duplicate-key retry path; xact_rollback must stay flat because a unique-violation + * aborts the initiateStep transaction. + */ + +import { + cleanupCreatedData, + createKycStep, + createUser, + expect, + queryOne, + queryRows, + test, + withDb, +} from './fixtures'; + +test.describe.configure({ mode: 'serial' }); + +test.beforeAll(async () => { + await ensureKycStepUniqueNullsNotDistinct(); +}); + +test.afterAll(async () => { + await cleanupCreatedData(); +}); + +const PARALLEL_CONTINUES = 13; +/** Trusted client IP for loc realIp middleware (`cf-connecting-ip`). */ +const TFA_IP = '203.0.113.7'; +/** Production unique index on kyc_step (userDataId, name, type, sequenceNumber). */ +const KYC_STEP_UNIQUE_INDEX = 'IDX_3a1150791476264753a67212a1'; + +function apiBase(): string { + return process.env.E2E_API_URL ?? 'http://api:3000'; +} + +async function kycHashOf(userDataId: number): Promise { + const row = await queryOne<{ kycHash: string }>(`SELECT "kycHash" FROM user_data WHERE id = $1`, [userDataId]); + if (!row?.kycHash) throw new Error(`user_data.kycHash missing for userDataId ${userDataId}`); + return row.kycHash; +} + +async function countSteps(userDataId: number, name: string): Promise { + const row = await queryOne<{ n: string }>( + `SELECT COUNT(*)::text AS n FROM kyc_step WHERE "userDataId" = $1 AND name = $2`, + [userDataId, name], + ); + return Number(row?.n ?? 0); +} + +async function rollbackCount(): Promise { + const row = await queryOne<{ n: string }>( + `SELECT xact_rollback::text AS n FROM pg_stat_database WHERE datname = current_database()`, + ); + return Number(row?.n ?? 0); +} + +/** + * Synchronize does not apply FixNullableUniqueIndexes. Recreate the production unique index + * so NULL `type` on FinancialData actually conflicts (otherwise COUNT=1 is the only signal + * and xact_rollback never moves). + */ +async function ensureKycStepUniqueNullsNotDistinct(): Promise { + await withDb(async (client) => { + const { rows } = await client.query<{ indexname: string; indexdef: string }>( + `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'kyc_step'`, + ); + for (const idx of rows) { + const def = idx.indexdef.toLowerCase(); + if ( + def.includes('unique') && + def.includes('userdataid') && + def.includes('sequencenumber') && + (def.includes('"type"') || def.includes(', type,') || def.includes('(type')) + ) { + await client.query(`DROP INDEX IF EXISTS "${idx.indexname}"`); + } + } + await client.query( + `CREATE UNIQUE INDEX "${KYC_STEP_UNIQUE_INDEX}" ON "kyc_step" ("userDataId", "name", "type", "sequenceNumber") NULLS NOT DISTINCT`, + ); + }); + const row = await queryOne<{ indexdef: string }>(`SELECT indexdef FROM pg_indexes WHERE indexname = $1`, [ + KYC_STEP_UNIQUE_INDEX, + ]); + expect(row?.indexdef ?? '').toMatch(/NULLS NOT DISTINCT/i); +} + +async function ensureCompletedStep( + userDataId: number, + name: string, + extra: { type?: string | null; result?: string | null } = {}, +): Promise { + const existing = await queryOne<{ id: number }>( + `SELECT id FROM kyc_step WHERE "userDataId" = $1 AND name = $2 ORDER BY id LIMIT 1`, + [userDataId, name], + ); + if (existing) { + await withDb(async (client) => { + await client.query( + `UPDATE kyc_step SET status = 'Completed', result = COALESCE($2, result), updated = NOW() WHERE id = $1`, + [existing.id, extra.result ?? null], + ); + }); + return; + } + await createKycStep(userDataId, { + name, + status: 'Completed', + sequenceNumber: 0, + type: extra.type, + result: extra.result, + }); +} + +async function seedPriorSteps(userDataId: number): Promise { + // Signup/mail already inserts ContactData. Re-inserting the same NULL-type row only + // succeeds on the synchronize unique index; production NULLS NOT DISTINCT rejects it. + await ensureCompletedStep(userDataId, 'ContactData'); + await ensureCompletedStep(userDataId, 'PersonalData'); + await ensureCompletedStep(userDataId, 'NationalityData', { + result: JSON.stringify({ nationality: { symbol: 'CH' } }), + }); + await withDb(async (client) => { + await client.query(`UPDATE user_data SET "tradeApprovalDate" = NOW() WHERE id = $1`, [userDataId]); + }); +} + +async function seedIdentCompleted(tag: string): Promise<{ userDataId: number; kycHash: string }> { + const user = await createUser({ + tag, + language: 'EN', + country: 'CH', + kycLevel: 30, + completePersonalData: true, + }); + await seedPriorSteps(user.userDataId); + await ensureCompletedStep(user.userDataId, 'Ident', { type: 'SumsubAuto' }); + return { userDataId: user.userDataId, kycHash: await kycHashOf(user.userDataId) }; +} + +async function markStrictTfa(userDataId: number): Promise { + // continue() requires a STRICT TfaLog for the request IP once FinancialData/Ident is in progress. + await withDb(async (client) => { + for (const ip of [TFA_IP, '127.0.0.1', '::1', '::ffff:127.0.0.1', 'unknown']) { + await client.query( + `INSERT INTO kyc_log (type, comment, "userDataId", "ipAddress", created, updated) + VALUES ('TfaLog', 'Strict (App)', $1, $2, NOW(), NOW())`, + [userDataId, ip], + ); + } + }); +} + +async function putContinue(kycHash: string): Promise<{ status: number }> { + const res = await fetch(`${apiBase()}/v2/kyc`, { + method: 'PUT', + headers: { + Accept: 'application/json', + 'x-kyc-code': kycHash, + 'cf-connecting-ip': TFA_IP, + }, + }); + return { status: res.status }; +} + +test('13 parallel PUT /v2/kyc after ident create exactly one FinancialData step', async () => { + const user = await seedIdentCompleted('continue-race-api'); + await markStrictTfa(user.userDataId); + expect(await countSteps(user.userDataId, 'FinancialData')).toBe(0); + + const rollbacksBefore = await rollbackCount(); + const results = await Promise.all(Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(user.kycHash))); + + const statuses = results.map((r) => r.status); + expect( + statuses.every((s) => s === 200), + `continue statuses: ${statuses.join(',')}`, + ).toBe(true); + expect(await countSteps(user.userDataId, 'FinancialData')).toBe(1); + + const rows = await queryRows<{ id: number; status: string }>( + `SELECT id, status FROM kyc_step WHERE "userDataId" = $1 AND name = 'FinancialData' ORDER BY id`, + [user.userDataId], + ); + expect(rows).toHaveLength(1); + + const rollbacksAfter = await rollbackCount(); + expect(rollbacksAfter - rollbacksBefore).toBe(0); +}); + +test('a fourteenth continue after the burst still leaves one FinancialData step', async () => { + const user = await seedIdentCompleted('continue-race-fourteenth'); + await markStrictTfa(user.userDataId); + + const burst = await Promise.all(Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(user.kycHash))); + const fourteenth = await putContinue(user.kycHash); + const statuses = [...burst.map((r) => r.status), fourteenth.status]; + + expect( + statuses.every((s) => s === 200), + `continue statuses: ${statuses.join(',')}`, + ).toBe(true); + expect(await countSteps(user.userDataId, 'FinancialData')).toBe(1); +}); + +test('two users racing 13 continues each still get one FinancialData step apiece', async () => { + // Still one FinancialData per user under load. Does not distinguish a per-user advisory + // lock from a process-wide mutex; the production incident is one user, 13 continues. + const userA = await seedIdentCompleted('continue-race-two-a'); + const userB = await seedIdentCompleted('continue-race-two-b'); + await markStrictTfa(userA.userDataId); + await markStrictTfa(userB.userDataId); + + const results = await Promise.all([ + ...Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(userA.kycHash)), + ...Array.from({ length: PARALLEL_CONTINUES }, () => putContinue(userB.kycHash)), + ]); + + const statuses = results.map((r) => r.status); + expect( + statuses.every((s) => s === 200), + `continue statuses: ${statuses.join(',')}`, + ).toBe(true); + expect(await countSteps(userA.userDataId, 'FinancialData')).toBe(1); + expect(await countSteps(userB.userDataId, 'FinancialData')).toBe(1); +}); diff --git a/e2e-stack/specs/support-dashboard.spec.ts b/e2e-stack/specs/support-dashboard.spec.ts index 47016263f..d59ca032f 100644 --- a/e2e-stack/specs/support-dashboard.spec.ts +++ b/e2e-stack/specs/support-dashboard.spec.ts @@ -131,7 +131,9 @@ test.describe('Support dashboard (staff)', () => { ]); const issue = required(issueRow, 'createLimitRequest must leave a support_issue row'); - const { jwt } = await loginAs('Support'); + // Customer LimitRequests are filed under Department.Compliance. Support's issue list is + // restricted to Department.Support, so this listing uses Compliance (who can open the dashboard). + const { jwt } = await loginAs('Compliance'); await openScreen(page, '/support/dashboard/all', jwt); await page.getByRole('button', { name: /^Limit Requests \(/ }).click(); diff --git a/e2e/realunit-compliance.spec.ts b/e2e/realunit-compliance.spec.ts index d6f3a0690..9da526d2f 100644 --- a/e2e/realunit-compliance.spec.ts +++ b/e2e/realunit-compliance.spec.ts @@ -226,6 +226,14 @@ const DOSSIER = { created: '2024-01-02T00:00:00.000Z', }, ], + addresses: [ + { + id: 7901, + address: '0xabc0000000000000000000000000000000000001', + status: 'Active', + created: '2024-01-02T00:00:00.000Z', + }, + ], buyRoutes: [ { id: 7501, @@ -393,6 +401,8 @@ test.describe('RealUnit Compliance dashboards - Visual Regression Tests', () => // title AND the identity "Account Type" value). await expect(page.getByText('Identity')).toBeVisible(); await expect(page.getByText('Account Opener Authorization', { exact: false })).toBeVisible(); + // Wallet address from the Addresses table (Buy Routes keep the same hex as targetAddress but do not render it). + await expect(page.getByText('0xabc0000000000000000000000000000000000001')).toBeVisible(); await expect(page.getByText('Support Issues', { exact: false })).toBeVisible(); await expect(page.getByText('Missing incoming transfer')).toBeVisible(); diff --git a/e2e/safe-accounts.spec.ts b/e2e/safe-accounts.spec.ts index 897a8c930..18d34cdc7 100644 --- a/e2e/safe-accounts.spec.ts +++ b/e2e/safe-accounts.spec.ts @@ -9,9 +9,9 @@ import { getCachedAuth } from './helpers/auth-cache'; * repeated here — the switcher is not rendered at all in those. * * Requires a local dataset with three reachable accounts: one owned (write), one shared - * read-only, and one shared with a write mandate. The last one matters: a mandate over - * someone else's Safe cannot transact either, because orders carry no account and would be - * booked against the caller's own. Not a CI regression gate — see CONTRIBUTING.md. + * read-only, and one shared with a write mandate. The last one matters: a write mandate + * can transact, because orders now hit the account resource. Not a CI regression gate — + * see CONTRIBUTING.md. */ test.describe('DFX Safe - Account switcher', () => { @@ -78,11 +78,11 @@ test.describe('DFX Safe - Account switcher', () => { await page.getByText('Example Mandate AG').click(); await page.waitForLoadState('networkidle'); - // The mandate grants write, yet acting is still refused — and the entry says so rather - // than looking fully usable and dropping the section on selection. - await expect(page.getByRole('button', { name: 'Deposit', exact: true })).toHaveCount(0); - await expect(page.getByText('View only').first()).toBeVisible(); + // Write mandate can transact: orders hit the account resource. + await expect(page.getByRole('button', { name: 'Deposit', exact: true })).toBeVisible(); + await expect(page.getByText('View only')).toHaveCount(0); + // Baseline follows; visuals are not a CI gate. await expect(page).toHaveScreenshot('04-account-shared-write-mandate.png', screenshotOpts); }); }); diff --git a/src/__tests__/realunit-compliance-kyc-file-date.test.tsx b/src/__tests__/realunit-compliance-kyc-file-date.test.tsx index 9e9534277..495935317 100644 --- a/src/__tests__/realunit-compliance-kyc-file-date.test.tsx +++ b/src/__tests__/realunit-compliance-kyc-file-date.test.tsx @@ -55,6 +55,7 @@ const DOSSIER: RealUnitCustomerDetailDto = { kycSteps: [], transactions: [], bankDatas: [], + addresses: [], buyRoutes: [], sellRoutes: [], swapRoutes: [], diff --git a/src/__tests__/realunit-compliance-user.screen.test.tsx b/src/__tests__/realunit-compliance-user.screen.test.tsx new file mode 100644 index 000000000..d7e9c0e0c --- /dev/null +++ b/src/__tests__/realunit-compliance-user.screen.test.tsx @@ -0,0 +1,99 @@ +// Component tests for the RealUnit compliance customer dossier Addresses CollectionTable. +// Heavy transitive deps are mocked so the screen can render under @testing-library/react without the full app shell. + +jest.mock('@dfx.swiss/react', () => ({})); +jest.mock('@dfx.swiss/react-components', () => ({ + SpinnerSize: { SM: 'sm', LG: 'lg' }, + StyledLoadingSpinner: () => null, +})); +jest.mock('src/components/error-hint', () => ({ ErrorHint: () => null })); +jest.mock('src/components/support/info-panel', () => ({ + InfoPanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + InfoRow: () => null, + SupportMessageList: () => null, +})); +jest.mock('src/hooks/guard.hook', () => ({ + useRealunitGuard: () => undefined, +})); +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ translate: (_ns: string, key: string) => key }), +})); +jest.mock('src/hooks/layout-config.hook', () => ({ + useLayoutOptions: () => undefined, +})); +jest.mock('react-router-dom', () => ({ + useParams: () => ({ id: '7' }), +})); + +const mockGetCustomer = jest.fn(); +jest.mock('src/hooks/realunit-compliance.hook', () => ({ + useRealunitCompliance: () => ({ + getCustomer: mockGetCustomer, + downloadFile: jest.fn(), + downloadDossier: jest.fn(), + }), +})); + +import { render, screen, waitFor } from '@testing-library/react'; +import { RealUnitCustomerDetailDto } from 'src/dto/realunit-compliance.dto'; +import RealunitComplianceUserScreen from 'src/screens/realunit-compliance-user.screen'; + +function minimalCustomer(overrides: Partial = {}): RealUnitCustomerDetailDto { + return { + id: 7, + created: '2024-01-01T00:00:00.000Z', + kycStatus: 'Completed', + checks: {}, + kycFiles: [], + kycSteps: [], + transactions: [], + bankDatas: [], + addresses: [], + buyRoutes: [], + sellRoutes: [], + swapRoutes: [], + virtualIbans: [], + supportIssues: [], + ...overrides, + }; +} + +describe('RealunitComplianceUserScreen Addresses table', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Addresses heading and wallet address', async () => { + mockGetCustomer.mockResolvedValue( + minimalCustomer({ + addresses: [ + { + id: 11, + address: '0xrealunitonly', + status: 'Active', + created: '2024-01-02T00:00:00.000Z', + }, + ], + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Addresses/)).toBeInTheDocument(); + expect(screen.getByText('0xrealunitonly')).toBeInTheDocument(); + expect(screen.getByText('11')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + + it('shows No addresses when the list is empty', async () => { + mockGetCustomer.mockResolvedValue(minimalCustomer({ addresses: [] })); + + render(); + + await waitFor(() => { + expect(screen.getByText('No addresses')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/safe/account-selector.tsx b/src/components/safe/account-selector.tsx index 1a8c356d8..4a0065333 100644 --- a/src/components/safe/account-selector.tsx +++ b/src/components/safe/account-selector.tsx @@ -42,9 +42,9 @@ export function AccountSelector({ accounts, selected, onSelect }: AccountSelecto smallLabel items={accounts} labelFunc={(account) => account.title} - // Labelled by the same predicate the screen acts on: a mandate over someone else's - // account cannot transact either, and an entry that looks fully usable but silently - // drops the transaction area on selection is worse than one that says so upfront. + // Labelled by the same predicate the screen acts on: View only when the caller cannot + // act (Read, own or foreign). A write mandate is no longer View only, because orders + // now go through the account resource. descriptionFunc={(account) => (canActOn(account) ? '' : translate('screens/safe', 'View only'))} /> diff --git a/src/dto/realunit-compliance.dto.ts b/src/dto/realunit-compliance.dto.ts index 15911b924..afc3a8148 100644 --- a/src/dto/realunit-compliance.dto.ts +++ b/src/dto/realunit-compliance.dto.ts @@ -37,6 +37,14 @@ export interface RealUnitDossierMessage { created: string; } +// RealUnit-app wallet row from the user table (filtered api-side to this customer's wallets). +export interface RealUnitWalletAddress { + id: number; + address: string; + status: string; + created: string; // ISO over the wire +} + export interface RealUnitBuyRoute { id: number; iban?: string; @@ -234,6 +242,7 @@ export interface RealUnitCustomerDetailDto { kycSteps: RealUnitDossierKycStepDto[]; transactions: RealUnitDossierTxDto[]; bankDatas: RealUnitDossierBankDataDto[]; + addresses: RealUnitWalletAddress[]; buyRoutes: RealUnitBuyRoute[]; sellRoutes: RealUnitSellRoute[]; swapRoutes: RealUnitSwapRoute[]; diff --git a/src/hooks/safe.hook.ts b/src/hooks/safe.hook.ts index fcbd33c2c..285ecbe29 100644 --- a/src/hooks/safe.hook.ts +++ b/src/hooks/safe.hook.ts @@ -357,7 +357,7 @@ export function useSafe(): UseSafeResult { async function fetchPaymentInfo(data: OrderFormData): Promise { const order = await call({ - url: 'custody/order', + url: accountPath(selectedAccount, 'order', 'custody/order'), method: 'POST', data: { type: CustodyOrderType.DEPOSIT, @@ -375,7 +375,7 @@ export function useSafe(): UseSafeResult { async function fetchReceiveInfo(data: OrderFormData): Promise { const order = await call({ - url: 'custody/order', + url: accountPath(selectedAccount, 'order', 'custody/order'), method: 'POST', data: { type: CustodyOrderType.RECEIVE, @@ -392,7 +392,7 @@ export function useSafe(): UseSafeResult { async function fetchSwapInfo(data: OrderFormData): Promise { const order = await call({ - url: 'custody/order', + url: accountPath(selectedAccount, 'order', 'custody/order'), method: 'POST', data: { type: CustodyOrderType.SWAP, @@ -410,7 +410,7 @@ export function useSafe(): UseSafeResult { async function fetchWithdrawInfo(data: OrderFormData): Promise { const order = await call({ - url: 'custody/order', + url: accountPath(selectedAccount, 'order', 'custody/order'), method: 'POST', data: { type: CustodyOrderType.WITHDRAWAL, @@ -429,7 +429,7 @@ export function useSafe(): UseSafeResult { async function fetchSendInfo(data: SendOrderFormData): Promise { const order = await call({ - url: 'custody/order', + url: accountPath(selectedAccount, 'order', 'custody/order'), method: 'POST', data: { type: CustodyOrderType.SEND, diff --git a/src/screens/kyc.screen.tsx b/src/screens/kyc.screen.tsx index 93e7aade7..3ae52e758 100644 --- a/src/screens/kyc.screen.tsx +++ b/src/screens/kyc.screen.tsx @@ -70,7 +70,7 @@ import { StyledVerticalStack, } from '@dfx.swiss/react-components'; import SumsubWebSdk from '@sumsub/websdk-react'; -import { RefObject, useEffect, useState } from 'react'; +import { RefObject, useEffect, useRef, useState } from 'react'; import { isMobile } from 'react-device-detect'; import { useForm, useWatch } from 'react-hook-form'; import { Trans } from 'react-i18next'; @@ -90,6 +90,7 @@ import { useUserGuard } from '../hooks/guard.hook'; import { useKycHelper } from '../hooks/kyc-helper.hook'; import { useLayoutOptions } from '../hooks/layout-config.hook'; import { useNavigation } from '../hooks/navigation.hook'; +import { createKeyedSerial } from '../util/single-flight'; import { delay, toBase64, url } from '../util/utils'; import { AddressZipValidation } from '../util/validation-rules'; import { IframeMessageType } from './kyc-redirect.screen'; @@ -127,6 +128,8 @@ export default function KycScreen(): JSX.Element { const [showLinkHint, setShowLinkHint] = useState(false); const [isCanceling, setIsCanceling] = useState(false); const { rootRef } = useLayoutContext(); + const loadSerial = useRef(createKeyedSerial()); + const loadGen = useRef(0); const mode = pathname.includes('/profile') ? Mode.PROFILE : pathname.includes('/contact') ? Mode.CONTACT : Mode.KYC; const urlParams = new URLSearchParams(search); @@ -199,27 +202,41 @@ export default function KycScreen(): JSX.Element { stepSequence ? +stepSequence : undefined, ), ) - .then(handleReload) + .then((session) => { + setError(undefined); + return handleReload(session); + }) .then(() => clearParams(['step'])) - : callKyc(() => getKycInfo(kycCode)).then(handleInitial); + : callKyc(() => getKycInfo(kycCode)).then(async (info) => { + setError(undefined); + await handleInitial(info); + }); - request - .then(() => setError(undefined)) - .catch((error: ApiError) => setError(error.message ?? 'Unknown error')) - .finally(() => setIsLoading(false)); + request.catch((error: ApiError) => setError(error.message ?? 'Unknown error')).finally(() => setIsLoading(false)); }, [kycCode, stepName, stepType]); async function onLoad(next: boolean): Promise { if (!kycCode) return; - setIsSubmitting(true); - setError(undefined); - setShowLinkHint(false); - setConsentClient(undefined); - return (next ? callKyc(() => continueKyc(kycCode)) : callKyc(() => getKycInfo(kycCode))) - .then(handleReload) - .catch((error: ApiError) => setError(error.message ?? 'Unknown error')) - .finally(() => setIsSubmitting(false)); + return loadSerial.current(next ? 'continue' : 'info', () => { + const gen = ++loadGen.current; + setIsSubmitting(true); + setError(undefined); + setShowLinkHint(false); + setConsentClient(undefined); + return (next ? callKyc(() => continueKyc(kycCode)) : callKyc(() => getKycInfo(kycCode))) + .then((info) => { + if (gen !== loadGen.current) return; + return handleReload(info); + }) + .catch((error: ApiError) => { + if (gen !== loadGen.current) return; + setError(error.message ?? 'Unknown error'); + }) + .finally(() => { + if (gen === loadGen.current) setIsSubmitting(false); + }); + }); } async function handleInitial(info: KycInfo): Promise { @@ -229,7 +246,7 @@ export default function KycScreen(): JSX.Element { if (info.kycLevel >= RequiredKycLevel[mode] || !kycCode) { goBack(); } else { - return callKyc(() => continueKyc(kycCode)).then(handleReload); + return onLoad(true); } } } @@ -1800,24 +1817,31 @@ function Ident({ step, lang, onDone, onBack, onError }: EditProps): JSX.Element const [isDone, setIsDone] = useState(false); const [error, setError] = useState(); + const onDoneRef = useRef(onDone); + const onBackRef = useRef(onBack); + onDoneRef.current = onDone; + onBackRef.current = onBack; + useEffect(() => { - onDone(); + if (!isDone) return; - const refreshInterval = setInterval(() => isDone && onDone(), 1000); + onDoneRef.current(); + const refreshInterval = setInterval(() => onDoneRef.current(), 1000); return () => clearInterval(refreshInterval); }, [isDone]); - // listen to close events useEffect(() => { + function onMessage(e: Event) { + const message = (e as MessageEvent<{ type: string; status: KycStepStatus }>).data; + if (message.type === IframeMessageType) { + isStepDone(message as KycStepBase) ? onDoneRef.current() : onBackRef.current(); + } + } + window.addEventListener('message', onMessage); - return () => window.removeEventListener('keydown', onMessage); + return () => window.removeEventListener('message', onMessage); }, []); - function onMessage(e: Event) { - const message = (e as MessageEvent<{ type: string; status: KycStepStatus }>).data; - if (message.type === IframeMessageType) isStepDone(message as KycStepBase) ? onDone() : onBack(); - } - return step.session ? ( error ? (
diff --git a/src/screens/realunit-compliance-user.screen.tsx b/src/screens/realunit-compliance-user.screen.tsx index e815be908..11e984b7f 100644 --- a/src/screens/realunit-compliance-user.screen.tsx +++ b/src/screens/realunit-compliance-user.screen.tsx @@ -335,6 +335,22 @@ export default function RealunitComplianceUserScreen(): JSX.Element { ]} /> + {/* Addresses (RealUnit-app wallet rows) */} + a.id }, + { + header: translate('screens/compliance', 'Address'), + render: (a) => {a.address}, + }, + { header: translate('screens/compliance', 'Status'), render: (a) => statusBadge(a.status) }, + { header: translate('screens/compliance', 'Created'), render: (a) => formatDate(a.created) }, + ]} + /> + {/* Buy Routes */}