├── HISTORY.md ├── server ├── keys │ └── .gitkeep ├── .npmrc ├── src │ ├── utils │ │ ├── fetch.ts │ │ └── tggl.ts │ ├── env.ts │ ├── views │ │ ├── error.html │ │ └── auth-error.html │ ├── graphql │ │ ├── unauthenticated.gql │ │ └── partner.gql │ └── api │ │ └── oauth2.swan.ts ├── tsconfig.json └── package.json ├── .hooks └── pre-commit ├── types ├── global │ └── index.d.ts ├── window │ └── index.d.ts ├── navigator │ └── index.d.ts └── env │ └── index.d.ts ├── pnpm-workspace.yaml ├── tests ├── utils │ ├── constants.ts │ ├── env.ts │ ├── i18n.ts │ ├── session.ts │ ├── twilio.ts │ ├── webhook.ts │ └── selectors.ts ├── assets │ ├── sworn_statement.png │ └── ubo_declaration.png ├── global-teardown.ts ├── global-setup.ts ├── graphql │ ├── partner.gql │ └── partner-admin.gql └── 4-profile.banking.ts ├── docs ├── pnpm-workspace.yaml ├── .npmrc ├── babel.config.js ├── static │ └── img │ │ ├── banking.png │ │ ├── favicon.png │ │ └── gradient.png ├── docs │ ├── specs │ │ ├── banking │ │ │ ├── images │ │ │ │ ├── nav-main.png │ │ │ │ ├── profile.png │ │ │ │ ├── cards-empty.png │ │ │ │ ├── members-new.png │ │ │ │ ├── cards-multiple.png │ │ │ │ ├── cards-single.png │ │ │ │ ├── history-main.png │ │ │ │ ├── members-main.png │ │ │ │ ├── nav-section1.png │ │ │ │ ├── nav-section2.png │ │ │ │ ├── nav-section3.png │ │ │ │ ├── nav-section4.png │ │ │ │ ├── transfer-home.png │ │ │ │ ├── transfer-new.png │ │ │ │ ├── transfer-type.png │ │ │ │ ├── account-settings.png │ │ │ │ ├── navigation │ │ │ │ │ ├── full.png │ │ │ │ │ ├── user.png │ │ │ │ │ ├── picker.png │ │ │ │ │ ├── navigation.png │ │ │ │ │ ├── no-members.png │ │ │ │ │ ├── only-cards.png │ │ │ │ │ ├── no-account-access.png │ │ │ │ │ └── full-action-required.png │ │ │ │ ├── transfer-new-so.png │ │ │ │ ├── account-main-iban.png │ │ │ │ ├── history-tab-history.png │ │ │ │ ├── nav-section2-tags.png │ │ │ │ ├── account-virtual-ibans.png │ │ │ │ ├── history-tab-upcoming.png │ │ │ │ ├── history-account-statements.png │ │ │ │ └── account-virtual-iban-cancel.png │ │ │ ├── identity-verification-bypass.md │ │ │ ├── profile.md │ │ │ ├── banking.md │ │ │ ├── branding.md │ │ │ └── members.md │ │ └── onboarding │ │ │ ├── images │ │ │ ├── company-flow.png │ │ │ ├── company-finalize.png │ │ │ ├── company-owners.png │ │ │ ├── individual-email.png │ │ │ ├── individual-flow.png │ │ │ ├── company-legal-info.png │ │ │ ├── company-owner-add.png │ │ │ ├── company-org-details.png │ │ │ ├── company-preliminary.png │ │ │ ├── company-registration.png │ │ │ ├── individual-finalize.png │ │ │ ├── individual-location.png │ │ │ ├── individual-occupation.png │ │ │ ├── onboarding-validation.png │ │ │ └── company-owner-add-address.png │ │ │ ├── _finalize.mdx │ │ │ └── onboarding.md │ ├── _clients.mdx │ ├── lake-ui-kit.md │ ├── apis.md │ ├── dev-server.md │ ├── getting-started.mdx │ ├── deploy-as-is.mdx │ ├── user-sessions.md │ ├── graphql.md │ └── invitation.mdx ├── src │ ├── theme │ │ └── DocItem │ │ │ ├── Footer │ │ │ ├── styles.module.css │ │ │ └── index.js │ │ │ ├── Layout │ │ │ └── styles.module.css │ │ │ ├── TOC │ │ │ ├── Mobile │ │ │ │ ├── styles.module.css │ │ │ │ └── index.js │ │ │ └── Desktop │ │ │ │ └── index.js │ │ │ ├── Metadata │ │ │ └── index.js │ │ │ ├── Paginator │ │ │ └── index.js │ │ │ ├── index.js │ │ │ └── Content │ │ │ └── index.js │ ├── components │ │ └── HomepageFeatures │ │ │ └── styles.module.css │ └── css │ │ └── custom.css ├── .gitignore ├── README.md └── package.json ├── .npmrc ├── clients ├── banking │ ├── public │ │ ├── robots.txt │ │ ├── bulk-transfer-csv-template │ │ │ └── bulk-transfer.csv │ │ ├── favicon.ico │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── sworn-statement-template │ │ │ ├── en.pdf │ │ │ ├── es.pdf │ │ │ ├── it.pdf │ │ │ └── nl.pdf │ │ ├── power-of-attorney-template │ │ │ ├── de.pdf │ │ │ ├── en.pdf │ │ │ ├── es.pdf │ │ │ ├── fr.pdf │ │ │ └── it.pdf │ │ ├── monext-iframes │ │ │ ├── MaisonNeue-Bold.woff │ │ │ ├── MaisonNeue-Bold.woff2 │ │ │ ├── MaisonNeue-Book.woff │ │ │ ├── MaisonNeue-Book.woff2 │ │ │ ├── MaisonNeue-Demi.woff │ │ │ └── MaisonNeue-Demi.woff2 │ │ └── manifest.json │ ├── src │ │ ├── assets │ │ │ └── images │ │ │ │ ├── credit-limit │ │ │ │ ├── request-limit.jpg │ │ │ │ └── deferred-debit-doc.jpg │ │ │ │ ├── merchant │ │ │ │ ├── merchant-profile.jpg │ │ │ │ └── merchant-profile-docs.jpg │ │ │ │ ├── physical-card-placeholder.svg │ │ │ │ ├── logo-swan.svg │ │ │ │ └── google-pay.svg │ │ ├── graphql │ │ │ ├── unauthenticated.gql │ │ │ └── partner-admin.gql │ │ ├── utils │ │ │ ├── env.ts │ │ │ ├── date.ts │ │ │ ├── signout.ts │ │ │ ├── projectId.ts │ │ │ ├── accountMembership.ts │ │ │ ├── templateTranslations.ts │ │ │ ├── logger.ts │ │ │ ├── identification.ts │ │ │ ├── creditLimit.ts │ │ │ └── phone.ts │ │ ├── components │ │ │ ├── Redirect.tsx │ │ │ ├── Connection.tsx │ │ │ ├── LakeCopyTextLine.tsx │ │ │ ├── ProgressBar.tsx │ │ │ ├── CopyTextButton.tsx │ │ │ ├── CardItemPhysicalChoosePinForm.tsx │ │ │ ├── FoldableAlert.tsx │ │ │ ├── FiltersMobileContainer.tsx │ │ │ ├── CardWizardChoosePinModal.tsx │ │ │ ├── ErrorView.tsx │ │ │ ├── DetailLine.tsx │ │ │ ├── CardItemPhysicalRenewalWizard.tsx │ │ │ ├── TypePickerLink.tsx │ │ │ ├── CardCancelConfirmationModal.tsx │ │ │ └── VerificationRenewal │ │ │ │ └── VerificationRenewalFooter.tsx │ │ └── index.tsx │ ├── index.html │ └── package.json ├── onboarding │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── power-of-attorney-template │ │ │ ├── de.pdf │ │ │ ├── en.pdf │ │ │ ├── es.pdf │ │ │ ├── fr.pdf │ │ │ └── it.pdf │ │ └── sworn-statement-template │ │ │ ├── en.pdf │ │ │ ├── es.pdf │ │ │ ├── it.pdf │ │ │ └── nl.pdf │ ├── src │ │ ├── types │ │ │ └── wizard.d.ts │ │ ├── utils │ │ │ ├── env.ts │ │ │ ├── projectId.ts │ │ │ └── logger.ts │ │ ├── hooks │ │ │ └── useTitle.ts │ │ ├── components │ │ │ ├── Redirect.tsx │ │ │ ├── TrackPressable.tsx │ │ │ ├── StepTitle.tsx │ │ │ ├── DocumentationLink.tsx │ │ │ ├── TrackComponent.tsx │ │ │ ├── OnboardingStepContent.tsx │ │ │ ├── PartnershipFooter.tsx │ │ │ ├── SupportingDocumentCollectionSuccess.tsx │ │ │ └── ErrorView.tsx │ │ ├── main.css │ │ ├── pages │ │ │ ├── changeAdmin │ │ │ │ ├── ChangeAdminContext1.tsx │ │ │ │ ├── ChangeAdminConfirm.tsx │ │ │ │ ├── ChangeAdminContext2.tsx │ │ │ │ ├── ChangeAdminNewAdmin.tsx │ │ │ │ ├── ChangeAdminDocuments.tsx │ │ │ │ └── ChangeAdminRequester.tsx │ │ │ └── NotFoundPage.tsx │ │ ├── assets │ │ │ └── imgs │ │ │ │ └── logo-swan.svg │ │ └── index.tsx │ ├── index.html │ └── package.json └── payment │ ├── public │ ├── robots.txt │ └── favicon.ico │ ├── src │ ├── utils │ │ ├── env.ts │ │ ├── routes.ts │ │ ├── projectId.ts │ │ └── logger.ts │ ├── components │ │ ├── Redirect.tsx │ │ └── ErrorView.tsx │ ├── main.css │ ├── pages │ │ ├── NotFoundPage.tsx │ │ ├── CardErrorPage.tsx │ │ └── ExpiredPage.tsx │ ├── index.tsx │ └── App.tsx │ ├── index.html │ └── package.json ├── .github ├── CODEOWNERS └── workflows │ ├── docs.yml │ ├── release.yml │ ├── download-translations.yml │ └── publish-public-registry.yml ├── .prettierrc ├── .prettierignore ├── .editorconfig ├── .vscode └── extensions.json ├── Dockerfile ├── SECURITY.md ├── Dockerfile-swan ├── vite.config.ts ├── scripts ├── twilio │ └── getLastMessages.ts ├── cookie │ └── generateCookieKey.ts ├── env │ ├── writeEnvInterface.ts │ └── lintEnvVariables.ts ├── locales │ └── sort.ts ├── changelog │ └── getChangelog.ts ├── tests │ ├── testSetup.ts │ └── matchTranslations.ts ├── graphql │ └── downloadSchemas.ts ├── release │ ├── helpers.ts │ └── createPrerelease.ts ├── build │ └── index.ts └── deploy │ └── deploy.js ├── .gitignore ├── graphql.config.yml ├── tsconfig.json ├── .env.e2e.example ├── .env.example ├── LICENSE ├── biome.json └── playwright.config.ts /HISTORY.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/keys/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx lint-staged 3 | -------------------------------------------------------------------------------- /types/global/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "clients/*" 3 | - "server" 4 | -------------------------------------------------------------------------------- /tests/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const REDIRECT_URI = "https://www.swan.io"; 2 | -------------------------------------------------------------------------------- /docs/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | # workaround for https://github.com/pnpm/pnpm/issues/2412 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | lockfile-include-tarball-url=true 3 | save-exact=true 4 | node-linker=hoisted 5 | -------------------------------------------------------------------------------- /clients/banking/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /clients/onboarding/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /clients/payment/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /types/window/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | __E2E_TEST_KEY_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?: string; 3 | } 4 | -------------------------------------------------------------------------------- /clients/banking/public/bulk-transfer-csv-template/bulk-transfer.csv: -------------------------------------------------------------------------------- 1 | beneficiary_name,iban,amount,currency,label,reference -------------------------------------------------------------------------------- /docs/.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | lockfile-include-tarball-url=true 3 | save-exact=true 4 | node-linker=hoisted 5 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/static/img/banking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/static/img/banking.png -------------------------------------------------------------------------------- /docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/static/img/favicon.png -------------------------------------------------------------------------------- /docs/static/img/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/static/img/gradient.png -------------------------------------------------------------------------------- /server/.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | lockfile-include-tarball-url=true 3 | save-exact=true 4 | node-linker=hoisted 5 | -------------------------------------------------------------------------------- /clients/banking/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/favicon.ico -------------------------------------------------------------------------------- /clients/banking/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/logo192.png -------------------------------------------------------------------------------- /clients/banking/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/logo512.png -------------------------------------------------------------------------------- /clients/payment/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/payment/public/favicon.ico -------------------------------------------------------------------------------- /tests/assets/sworn_statement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/tests/assets/sworn_statement.png -------------------------------------------------------------------------------- /tests/assets/ubo_declaration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/tests/assets/ubo_declaration.png -------------------------------------------------------------------------------- /clients/onboarding/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/favicon.ico -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | clients/ @swan-io/front 2 | docs/ @swan-io/front 3 | types/ @swan-io/front 4 | 5 | scripts/ @bloodyowl 6 | server/ @bloodyowl -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/nav-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/nav-main.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/profile.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/cards-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/cards-empty.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/members-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/members-new.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 100, 4 | "trailingComma": "all", 5 | "plugins": ["prettier-plugin-organize-imports"] 6 | } 7 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/cards-multiple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/cards-multiple.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/cards-single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/cards-single.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/history-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/history-main.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/members-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/members-main.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/nav-section1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/nav-section1.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/nav-section2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/nav-section2.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/nav-section3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/nav-section3.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/nav-section4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/nav-section4.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/transfer-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/transfer-home.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/transfer-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/transfer-new.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/transfer-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/transfer-type.png -------------------------------------------------------------------------------- /tests/global-teardown.ts: -------------------------------------------------------------------------------- 1 | import { resetEmailAddresses } from "./utils/webhook"; 2 | 3 | export default async () => { 4 | await resetEmailAddresses(); 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.min.js 2 | 3 | LICENSE_REPORT.md 4 | coverage/ 5 | dist/ 6 | docs/.docusaurus/ 7 | docs/build/ 8 | node_modules/ 9 | pnpm-lock.yaml 10 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/account-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/account-settings.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/full.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/user.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/transfer-new-so.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/transfer-new-so.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-flow.png -------------------------------------------------------------------------------- /clients/banking/public/sworn-statement-template/en.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/sworn-statement-template/en.pdf -------------------------------------------------------------------------------- /clients/banking/public/sworn-statement-template/es.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/sworn-statement-template/es.pdf -------------------------------------------------------------------------------- /clients/banking/public/sworn-statement-template/it.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/sworn-statement-template/it.pdf -------------------------------------------------------------------------------- /clients/banking/public/sworn-statement-template/nl.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/sworn-statement-template/nl.pdf -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/account-main-iban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/account-main-iban.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/history-tab-history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/history-tab-history.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/nav-section2-tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/nav-section2-tags.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/picker.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-finalize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-finalize.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-owners.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-owners.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/individual-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/individual-email.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/individual-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/individual-flow.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /clients/banking/public/power-of-attorney-template/de.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/power-of-attorney-template/de.pdf -------------------------------------------------------------------------------- /clients/banking/public/power-of-attorney-template/en.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/power-of-attorney-template/en.pdf -------------------------------------------------------------------------------- /clients/banking/public/power-of-attorney-template/es.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/power-of-attorney-template/es.pdf -------------------------------------------------------------------------------- /clients/banking/public/power-of-attorney-template/fr.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/power-of-attorney-template/fr.pdf -------------------------------------------------------------------------------- /clients/banking/public/power-of-attorney-template/it.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/power-of-attorney-template/it.pdf -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/account-virtual-ibans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/account-virtual-ibans.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/history-tab-upcoming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/history-tab-upcoming.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/navigation.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/no-members.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/no-members.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/only-cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/only-cards.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-legal-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-legal-info.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-owner-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-owner-add.png -------------------------------------------------------------------------------- /clients/banking/public/monext-iframes/MaisonNeue-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/monext-iframes/MaisonNeue-Bold.woff -------------------------------------------------------------------------------- /clients/banking/public/monext-iframes/MaisonNeue-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/monext-iframes/MaisonNeue-Bold.woff2 -------------------------------------------------------------------------------- /clients/banking/public/monext-iframes/MaisonNeue-Book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/monext-iframes/MaisonNeue-Book.woff -------------------------------------------------------------------------------- /clients/banking/public/monext-iframes/MaisonNeue-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/monext-iframes/MaisonNeue-Book.woff2 -------------------------------------------------------------------------------- /clients/banking/public/monext-iframes/MaisonNeue-Demi.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/monext-iframes/MaisonNeue-Demi.woff -------------------------------------------------------------------------------- /clients/banking/public/monext-iframes/MaisonNeue-Demi.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/public/monext-iframes/MaisonNeue-Demi.woff2 -------------------------------------------------------------------------------- /clients/onboarding/public/power-of-attorney-template/de.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/power-of-attorney-template/de.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/power-of-attorney-template/en.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/power-of-attorney-template/en.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/power-of-attorney-template/es.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/power-of-attorney-template/es.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/power-of-attorney-template/fr.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/power-of-attorney-template/fr.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/power-of-attorney-template/it.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/power-of-attorney-template/it.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/sworn-statement-template/en.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/sworn-statement-template/en.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/sworn-statement-template/es.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/sworn-statement-template/es.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/sworn-statement-template/it.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/sworn-statement-template/it.pdf -------------------------------------------------------------------------------- /clients/onboarding/public/sworn-statement-template/nl.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/onboarding/public/sworn-statement-template/nl.pdf -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-org-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-org-details.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-preliminary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-preliminary.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-registration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-registration.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/individual-finalize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/individual-finalize.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/individual-location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/individual-location.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/individual-occupation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/individual-occupation.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/onboarding-validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/onboarding-validation.png -------------------------------------------------------------------------------- /clients/onboarding/src/types/wizard.d.ts: -------------------------------------------------------------------------------- 1 | declare type WizardStep = { 2 | id: T; 3 | label: string; 4 | errors: { fieldName: string; code: string }[]; 5 | }; 6 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/history-account-statements.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/history-account-statements.png -------------------------------------------------------------------------------- /clients/banking/src/assets/images/credit-limit/request-limit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/src/assets/images/credit-limit/request-limit.jpg -------------------------------------------------------------------------------- /clients/banking/src/assets/images/merchant/merchant-profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/src/assets/images/merchant/merchant-profile.jpg -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/account-virtual-iban-cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/account-virtual-iban-cancel.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/no-account-access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/no-account-access.png -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/images/company-owner-add-address.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/onboarding/images/company-owner-add-address.png -------------------------------------------------------------------------------- /docs/docs/specs/banking/images/navigation/full-action-required.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/docs/docs/specs/banking/images/navigation/full-action-required.png -------------------------------------------------------------------------------- /clients/banking/src/assets/images/credit-limit/deferred-debit-doc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/src/assets/images/credit-limit/deferred-debit-doc.jpg -------------------------------------------------------------------------------- /clients/banking/src/assets/images/merchant/merchant-profile-docs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/swan-partner-frontend/HEAD/clients/banking/src/assets/images/merchant/merchant-profile-docs.jpg -------------------------------------------------------------------------------- /clients/banking/src/graphql/unauthenticated.gql: -------------------------------------------------------------------------------- 1 | query ProjectLoginPage($projectId: ID!) { 2 | projectInfoById(id: $projectId) { 3 | id 4 | accentColor 5 | name 6 | logoUri 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { createTestResultsDir, deleteTestResultsDir } from "./utils/functions"; 2 | 3 | export default async () => { 4 | await deleteTestResultsDir(); 5 | await createTestResultsDir(); 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome", 4 | "editorconfig.editorconfig", 5 | "esbenp.prettier-vscode", 6 | "graphql.vscode-graphql", 7 | "graphql.vscode-graphql-syntax" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/Footer/styles.module.css: -------------------------------------------------------------------------------- 1 | .lastUpdated { 2 | margin-top: 0.2rem; 3 | font-style: italic; 4 | font-size: smaller; 5 | } 6 | 7 | @media (min-width: 997px) { 8 | .lastUpdated { 9 | text-align: right; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/Layout/styles.module.css: -------------------------------------------------------------------------------- 1 | .docItemContainer header + *, 2 | .docItemContainer article > *:first-child { 3 | margin-top: 0; 4 | } 5 | 6 | @media (min-width: 997px) { 7 | .docItemCol { 8 | max-width: 75% !important; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/graphql/partner.gql: -------------------------------------------------------------------------------- 1 | query AccountMembership($accountMembershipId: ID!) { 2 | accountMembership(id: $accountMembershipId) { 3 | id 4 | account { 5 | id 6 | number 7 | holder { 8 | id 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /clients/payment/src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | VERSION: __env.VERSION, 3 | IS_SWAN_MODE: __env.IS_SWAN_MODE, 4 | PAYMENT_URL: __env.PAYMENT_URL, 5 | CLIENT_CHECKOUT_API_KEY: __env.CLIENT_CHECKOUT_API_KEY as string | undefined, 6 | SWAN_ENVIRONMENT: __env.SWAN_ENVIRONMENT, 7 | }; 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | -------------------------------------------------------------------------------- /docs/docs/_clients.mdx: -------------------------------------------------------------------------------- 1 | - **Web Banking** (`banking`): banking interface where users can manage their financial needs around transactions, cards, payments, and memberships 2 | - **Onboarding** (`onboarding`): process of opening new accounts for your users that follows specific steps to meet legal requirements 3 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/TOC/Mobile/styles.module.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 997px) { 2 | /* Prevent hydration FOUC, as the mobile TOC needs to be server-rendered */ 3 | .tocMobile { 4 | display: none; 5 | } 6 | } 7 | 8 | @media print { 9 | .tocMobile { 10 | display: none; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/docs/lake-ui-kit.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: Lake (UI Kit) 3 | --- 4 | 5 | # Lake 6 | 7 | The clients use **Lake**, Swan's Design System. 8 | `@swan-io/lake` is our component kit. 9 | 10 | Refer to Lake's [repository](https://github.com/swan-io/lake) and [Storybook](https://swan-io.github.io/lake) for more information. 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22 AS builder 2 | WORKDIR /app 3 | ADD ./server/ ./ 4 | ENV PNPM_HOME="/pnpm" 5 | ENV PATH="$PNPM_HOME:$PATH" 6 | RUN npm install -g pnpm@latest-10 7 | RUN pnpm install --no-frozen-lockfile 8 | 9 | FROM node:22 10 | WORKDIR /app 11 | COPY --chown=node:node --from=builder /app ./ 12 | CMD ["npm", "start"] 13 | EXPOSE 8080 14 | -------------------------------------------------------------------------------- /clients/banking/src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | VERSION: __env.VERSION, 3 | IS_SWAN_MODE: __env.IS_SWAN_MODE, 4 | BANKING_URL: __env.BANKING_URL, 5 | PAYMENT_URL: __env.PAYMENT_URL, 6 | APP_TYPE: __env.SWAN_ENVIRONMENT, 7 | PLACEKIT_API_KEY: __env.CLIENT_PLACEKIT_API_KEY, 8 | TGGL_API_KEY: __env.TGGL_API_KEY, 9 | }; 10 | -------------------------------------------------------------------------------- /clients/onboarding/src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | VERSION: __env.VERSION, 3 | IS_SWAN_MODE: __env.IS_SWAN_MODE, 4 | SWAN_ENVIRONMENT: __env.SWAN_ENVIRONMENT, 5 | PLACEKIT_API_KEY: __env.CLIENT_PLACEKIT_API_KEY, 6 | TGGL_API_KEY: __env.TGGL_API_KEY, 7 | BANKING_URL: __env.BANKING_URL, 8 | PAPPERS_API_URL: "", 9 | }; 10 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The **last version** of this project is supported with security updates. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a vulnerability, follow the process described in our [Vulnerability Disclosure documentation](https://docs.swan.io/help/vulnerability-disclosure). 10 | -------------------------------------------------------------------------------- /docs/docs/apis.md: -------------------------------------------------------------------------------- 1 | # APIs 2 | 3 | The server exposes two GraphQL API endpoints: 4 | 5 | - **POST `/api/partner`**: Swan Partner API. 6 | This endpoint requires the user to have an active session. 7 | - **POST `/api/unauthenticated`**: Swan Unauthenticated API. 8 | This is used for the onboarding process before the user had a chance to authenticate. 9 | -------------------------------------------------------------------------------- /Dockerfile-swan: -------------------------------------------------------------------------------- 1 | FROM node:22 AS builder 2 | WORKDIR /app 3 | ADD ./server/ ./ 4 | ENV PNPM_HOME="/pnpm" 5 | ENV PATH="$PNPM_HOME:$PATH" 6 | RUN npm install -g pnpm@latest-10 7 | RUN pnpm install --no-frozen-lockfile 8 | 9 | FROM node:22 10 | WORKDIR /app 11 | COPY --chown=node:node --from=builder /app ./ 12 | CMD ["npm", "run", "start-swan"] 13 | EXPOSE 8080 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | resolve: { 5 | alias: { "react-native": "react-native-web" }, 6 | }, 7 | test: { 8 | environment: "jsdom", 9 | watch: false, 10 | include: ["clients/**/*.test.(ts|tsx)"], 11 | setupFiles: ["scripts/tests/testSetup.ts"], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /server/src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | export const fetchWithTimeout: typeof fetch = async (input, init) => { 2 | const controller = new AbortController(); 3 | const id = setTimeout(() => controller.abort(), 30_000); 4 | 5 | const response = await fetch(input, { 6 | ...init, 7 | signal: controller.signal, 8 | }); 9 | 10 | clearTimeout(id); 11 | return response; 12 | }; 13 | -------------------------------------------------------------------------------- /clients/onboarding/src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export const useTitle = (title: string) => { 4 | const previousRef = useRef(document.title); 5 | document.title = title; 6 | 7 | useEffect(() => { 8 | const { current } = previousRef; 9 | 10 | return () => { 11 | document.title = current; 12 | }; 13 | }, []); 14 | }; 15 | -------------------------------------------------------------------------------- /scripts/twilio/getLastMessages.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from "node:os"; 2 | import { getLastMessages } from "../../tests/utils/twilio"; 3 | 4 | const startDate = new Date(); 5 | startDate.setMinutes(startDate.getMinutes() - 15); 6 | 7 | getLastMessages(startDate) 8 | .then(messages => console.log(messages.join(EOL))) 9 | .catch(error => { 10 | console.error(error); 11 | process.exit(1); 12 | }); 13 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | type Props = { 4 | to: string; 5 | }; 6 | 7 | export const Redirect = ({ to }: Props) => { 8 | // biome-ignore lint/correctness/useExhaustiveDependencies(to): 9 | useEffect(() => { 10 | window.location.assign(to); 11 | // we want to trigger this effect only on mount 12 | }, []); 13 | 14 | return null; 15 | }; 16 | -------------------------------------------------------------------------------- /types/navigator/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Navigator { 2 | // Allow detection of Brave browser 3 | // https://github.com/brave/brave-browser/issues/10165#issuecomment-641128278 4 | brave?: { 5 | isBrave: () => Promise; 6 | }; 7 | 8 | userAgentData?: { 9 | readonly brands: { brand: string; version: string }[]; 10 | readonly mobile: boolean; 11 | readonly platform: string; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /clients/banking/src/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { replaceUnsafe, useLocation } from "@swan-io/chicane"; 2 | import { useLayoutEffect } from "react"; 3 | 4 | export const Redirect = ({ to }: { to: string }) => { 5 | const location = useLocation().toString(); 6 | 7 | useLayoutEffect(() => { 8 | if (to !== location) { 9 | replaceUnsafe(to); 10 | } 11 | }, [to, location]); 12 | 13 | return null; 14 | }; 15 | -------------------------------------------------------------------------------- /clients/payment/src/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { replaceUnsafe, useLocation } from "@swan-io/chicane"; 2 | import { useLayoutEffect } from "react"; 3 | 4 | export const Redirect = ({ to }: { to: string }) => { 5 | const location = useLocation().toString(); 6 | 7 | useLayoutEffect(() => { 8 | if (to !== location) { 9 | replaceUnsafe(to); 10 | } 11 | }, [to, location]); 12 | 13 | return null; 14 | }; 15 | -------------------------------------------------------------------------------- /clients/banking/src/components/Connection.tsx: -------------------------------------------------------------------------------- 1 | import { Connection as ConnectionType, useForwardPagination } from "@swan-io/graphql-client"; 2 | import { ReactNode } from "react"; 3 | 4 | export const Connection = >({ 5 | connection, 6 | children, 7 | }: { 8 | connection: A; 9 | children: (value: A) => ReactNode; 10 | }) => { 11 | const paginated = useForwardPagination(connection); 12 | return children(paginated); 13 | }; 14 | -------------------------------------------------------------------------------- /clients/banking/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { locale } from "./i18n"; 3 | 4 | export const encodeDateTime = (date: string, time: string) => { 5 | const dateTime = dayjs(`${date} ${time}`, `${locale.dateFormat} ${locale.timeFormat}`); 6 | return dateTime.isValid() ? dateTime.toISOString() : ""; 7 | }; 8 | 9 | export const isToday = (date: string) => { 10 | const today = dayjs().format(locale.dateFormat); 11 | return date === today; 12 | }; 13 | -------------------------------------------------------------------------------- /types/env/index.d.ts: -------------------------------------------------------------------------------- 1 | declare const __env: { 2 | // Server provided 3 | VERSION: string; 4 | SWAN_PROJECT_ID?: string; 5 | TGGL_API_KEY?: string; 6 | SWAN_ENVIRONMENT: "SANDBOX" | "LIVE"; 7 | ACCOUNT_MEMBERSHIP_INVITATION_MODE: "LINK" | "EMAIL"; 8 | BANKING_URL: string; 9 | PAYMENT_URL: string; 10 | IS_SWAN_MODE: boolean; 11 | // Client 12 | CLIENT_PLACEKIT_API_KEY: string; 13 | CLIENT_ONBOARDING_MATOMO_SITE_ID: string; 14 | CLIENT_CHECKOUT_API_KEY: string; 15 | }; 16 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .svgContainer { 9 | width: 64px; 10 | height: 64px; 11 | background-color: #f7f5fb; 12 | border-radius: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | align-self: center; 17 | margin: 0 auto 20px; 18 | } 19 | 20 | .svg { 21 | width: 48px; 22 | height: 48px; 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/Metadata/index.js: -------------------------------------------------------------------------------- 1 | import { PageMetadata } from "@docusaurus/theme-common"; 2 | import { useDoc } from "@docusaurus/theme-common/internal"; 3 | export default function DocItemMetadata() { 4 | const { metadata, frontMatter, assets } = useDoc(); 5 | return ( 6 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/Paginator/index.js: -------------------------------------------------------------------------------- 1 | import { useDoc } from "@docusaurus/theme-common/internal"; 2 | import DocPaginator from "@theme/DocPaginator"; 3 | /** 4 | * This extra component is needed, because should remain generic. 5 | * DocPaginator is used in non-docs contexts too: generated-index pages... 6 | */ 7 | export default function DocItemPaginator() { 8 | const { metadata } = useDoc(); 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /clients/payment/src/main.css: -------------------------------------------------------------------------------- 1 | .card-number-frame, 2 | .expiry-date-frame, 3 | .cvv-frame { 4 | height: var(--spacing-40); 5 | background-color: #fff; 6 | height: 40px; 7 | border-radius: 6px; 8 | border-style: solid; 9 | border-width: 1px; 10 | border-color: #e8e7e8; 11 | box-sizing: border-box; 12 | display: "flex"; 13 | flex-grow: 1; 14 | } 15 | 16 | .card-number-frame-with-logo { 17 | border-right-width: 0; 18 | border-top-right-radius: 0; 19 | border-bottom-right-radius: 0; 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .DS_Store? 3 | 4 | node_modules/ 5 | npm-debug.log* 6 | 7 | clients/*/src/graphql/*.json 8 | clients/*/src/graphql/*.ts 9 | 10 | server/dist/ 11 | server/keys/_wildcard.swan.local-key.pem 12 | server/keys/_wildcard.swan.local.pem 13 | server/src/graphql/*.ts 14 | 15 | tests/graphql/*.json 16 | tests/graphql/*.ts 17 | tests/results 18 | 19 | !scripts/graphql/dist/ 20 | scripts/graphql/codegen-verify.yml 21 | scripts/graphql/tmp 22 | 23 | .env 24 | .env.e2e 25 | .env.swan 26 | 27 | localazy.keys.*.json 28 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/TOC/Desktop/index.js: -------------------------------------------------------------------------------- 1 | import { ThemeClassNames } from "@docusaurus/theme-common"; 2 | import { useDoc } from "@docusaurus/theme-common/internal"; 3 | import TOC from "@theme/TOC"; 4 | export default function DocItemTOCDesktop() { 5 | const { toc, frontMatter } = useDoc(); 6 | return ( 7 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /scripts/cookie/generateCookieKey.ts: -------------------------------------------------------------------------------- 1 | import pc from "picocolors"; 2 | import sodium from "sodium-native"; 3 | 4 | const buffer = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES); 5 | sodium.randombytes_buf(buffer); 6 | 7 | const hexKey = buffer.toString("hex"); 8 | 9 | console.log(``); 10 | console.log(`${pc.magenta("swan-partner-frontend")}`); 11 | console.log(`${pc.white("---")}`); 12 | console.log("you can paste the following key in the root .env file:"); 13 | console.log(``); 14 | console.log(hexKey); 15 | console.log(`${pc.white("---")}`); 16 | 17 | export {}; 18 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/TrackPressable.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, Ref } from "react"; 2 | import { GestureResponderEvent } from "react-native"; 3 | import { TrackComponent } from "./TrackComponent"; 4 | 5 | type Props = { 6 | ref?: Ref; 7 | action: string; 8 | children: ReactElement<{ 9 | ref?: unknown; 10 | onPress?: (event: GestureResponderEvent) => void; 11 | }>; 12 | }; 13 | 14 | export const TrackPressable = ({ ref, children, action }: Props) => ( 15 | 16 | {children} 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /clients/payment/src/utils/routes.ts: -------------------------------------------------------------------------------- 1 | import { createGroup, createRouter, InferRoutes } from "@swan-io/chicane"; 2 | 3 | export const Router = createRouter({ 4 | Preview: 5 | "/preview/?:accentColor&:logo&:amount&:currency&:label&:card&:sepaDirectDebit&:merchantName&:cancelUrl", 6 | ...createGroup("Payment", "/:paymentLinkId", { 7 | Area: "/*", 8 | Form: "/?:error{true}", 9 | Success: "/success", 10 | Expired: "/expired", 11 | }), 12 | }); 13 | 14 | type Routes = InferRoutes; 15 | 16 | export type RouteName = keyof Routes; 17 | export type RouteParams = Routes[T]; 18 | -------------------------------------------------------------------------------- /docs/docs/dev-server.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Development server 3 | sidebar_label: Development server 4 | --- 5 | 6 | After setup is complete, start the development server by running the following command: 7 | 8 | ```console 9 | $ pnpm dev 10 | ``` 11 | 12 | The command will provide the URLs you can access: 13 | 14 | ```console 15 | swan-partner-frontend 16 | --- 17 | dev server started 18 | 19 | Banking: https://banking.swan.local:8080 20 | Onboarding Individual: https://onboarding.swan.local:8080/onboarding/individual/start 21 | Onboarding Company: https://onboarding.swan.local:8080/onboarding/company/start 22 | --- 23 | ``` 24 | -------------------------------------------------------------------------------- /clients/banking/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Banking - Swan", 3 | "name": "Banking - Swan", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#14191a", 24 | "background_color": "#f9f9f9" 25 | } 26 | -------------------------------------------------------------------------------- /clients/banking/src/utils/signout.ts: -------------------------------------------------------------------------------- 1 | import { Request, badStatusToError } from "@swan-io/request"; 2 | import { showToast } from "@swan-io/shared-business/src/state/toasts"; 3 | import { translateError } from "@swan-io/shared-business/src/utils/i18n"; 4 | import { Router } from "./routes"; 5 | 6 | export const signout = () => { 7 | Request.make({ url: "/auth/logout", method: "POST", credentials: "include", type: "text" }) 8 | .mapOkToResult(badStatusToError) 9 | .tapOk(() => window.location.replace(Router.ProjectLogin())) 10 | .tapError(error => { 11 | showToast({ variant: "error", error, title: translateError(error) }); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /clients/onboarding/src/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | display: block; 3 | height: auto; 4 | } 5 | 6 | html { 7 | height: -webkit-fill-available; 8 | } 9 | 10 | body { 11 | height: auto; 12 | display: flex; 13 | flex-direction: column; 14 | overflow: auto; 15 | min-height: 100vh; 16 | background-color: var(--color-background-default); 17 | scroll-behavior: smooth; 18 | } 19 | 20 | /* Only on Safari */ 21 | @supports (-webkit-touch-callout: none) { 22 | body { 23 | min-height: -webkit-fill-available; 24 | } 25 | } 26 | 27 | #app-root, 28 | #root { 29 | display: flex; 30 | flex-direction: column; 31 | flex-grow: 1; 32 | height: auto; 33 | } 34 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/TOC/Mobile/index.js: -------------------------------------------------------------------------------- 1 | import { ThemeClassNames } from "@docusaurus/theme-common"; 2 | import { useDoc } from "@docusaurus/theme-common/internal"; 3 | import TOCCollapsible from "@theme/TOCCollapsible"; 4 | import clsx from "clsx"; 5 | import styles from "./styles.module.css"; 6 | export default function DocItemTOCMobile() { 7 | const { toc, frontMatter } = useDoc(); 8 | return ( 9 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /graphql.config.yml: -------------------------------------------------------------------------------- 1 | projects: 2 | partner: 3 | schema: "./scripts/graphql/dist/partner-schema.gql" 4 | documents: 5 | - "./clients/*/src/graphql/partner.gql" 6 | - "./server/src/graphql/partner.gql" 7 | - "./tests/graphql/partner.gql" 8 | partner-admin: 9 | schema: "./scripts/graphql/dist/partner-admin-schema.gql" 10 | documents: 11 | - "./clients/*/src/graphql/partner-admin.gql" 12 | - "./tests/graphql/partner-admin.gql" 13 | unauthenticated: 14 | schema: "./scripts/graphql/dist/unauthenticated-schema.gql" 15 | documents: 16 | - "./clients/*/src/graphql/unauthenticated.gql" 17 | - "./server/src/graphql/unauthenticated.gql" 18 | -------------------------------------------------------------------------------- /docs/docs/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | sidebar_label: Introduction 4 | --- 5 | 6 | import PartialExample from "./_clients.mdx"; 7 | 8 | **Swan Banking Frontend** is Swan's official client for user onboarding and web banking. 9 | 10 | 11 | 12 | This project might be interesting to you if: 13 | 14 | - You need to **bootstrap** your Swan UI quickly. 15 | - You need to **customize** your user experience, whether with fonts and colors or by eliminating optional components. 16 | - You'd like to **learn from Swan's reference** implementation. 17 | 18 | :::tip Prerequisites 19 | To use this project, you should have `git`, `node`, and `pnpm` installed on your machine. 20 | ::: 21 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/StepTitle.tsx: -------------------------------------------------------------------------------- 1 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 2 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 3 | import { colors } from "@swan-io/lake/src/constants/design"; 4 | 5 | type Props = { 6 | children: string; 7 | isMobile: boolean; 8 | }; 9 | 10 | export const StepTitle = ({ children, isMobile }: Props) => { 11 | if (isMobile) { 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/identity-verification-bypass.md: -------------------------------------------------------------------------------- 1 | # Bypass identity verification 2 | 3 | For specific use cases validated by Swan, account members might not need to verify their identity. 4 | All of the following must be `false`: 5 | 6 | - `projectInfo.B2BMembershipIDVerification` 7 | - `accountMembership.canManageAccountMembership` 8 | - `accountMembership.canInitiatePayments` 9 | - `accountMembership.canManageBeneficiaries` 10 | 11 | :::tip Sample use case 12 | 13 | The company issues Swan cards for expense management. 14 | An account member is a cardholder with no additional permissions, and their identity is already known. 15 | 16 | In this case, **if approved by Swan**, the user **should not be asked to verify their identity**. 17 | 18 | ::: 19 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/index.js: -------------------------------------------------------------------------------- 1 | import { HtmlClassNameProvider } from "@docusaurus/theme-common"; 2 | import { DocProvider } from "@docusaurus/theme-common/internal"; 3 | import DocItemLayout from "@theme/DocItem/Layout"; 4 | import DocItemMetadata from "@theme/DocItem/Metadata"; 5 | 6 | export default function DocItem(props) { 7 | const docHtmlClassName = `docs-doc-id-${props.content.metadata.unversionedId}`; 8 | const MDXComponent = props.content; 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | docs: 10 | name: Build docs 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: lts/* 20 | cache: pnpm 21 | 22 | - name: Install & build 23 | run: cd docs && pnpm install --frozen-lockfile && pnpm build 24 | 25 | - name: Deploy 26 | if: "contains('refs/heads/main', github.ref)" 27 | uses: peaceiris/actions-gh-pages@v3 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | publish_dir: ./docs/build 31 | -------------------------------------------------------------------------------- /clients/banking/src/graphql/partner-admin.gql: -------------------------------------------------------------------------------- 1 | query SandboxUsers($first: Int!, $after: String, $orderBy: SandboxUsersOrderByInput!) { 2 | sandboxUser { 3 | id 4 | firstName 5 | lastName 6 | } 7 | sandboxUsers(first: $first, after: $after, orderBy: $orderBy) { 8 | totalCount 9 | pageInfo { 10 | hasNextPage 11 | endCursor 12 | } 13 | edges { 14 | cursor 15 | node { 16 | id 17 | firstName 18 | lastName 19 | } 20 | } 21 | } 22 | } 23 | 24 | query SandboxUser { 25 | sandboxUser { 26 | id 27 | firstName 28 | lastName 29 | } 30 | } 31 | 32 | mutation EndorseSandboxUser($input: EndorseSandboxUserInput!) { 33 | endorseSandboxUser(input: $input) { 34 | __typename 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/profile.md: -------------------------------------------------------------------------------- 1 | # Profile 2 | 3 | Along with the main navigation, the **user profile** should include the following content: 4 | 5 | - User's first and last name 6 | - Email address (connected to account memberships) 7 | - Phone number 8 | - User creation date 9 | - Identification status 10 | - Language preference 11 | 12 | Additionally, there should be links to various user resources, whether to your own or to [Swan Customer Support](https://support.swan.io/hc). 13 | 14 | :::info Default language 15 | By default, the language matches the `navigation.languages` value of the user's web browser. 16 | If the user overrides this default, the override is stored in the client’s local storage. 17 | ::: 18 | 19 | ![Screenshot of a user profile with sample content](./images/profile.png) 20 | -------------------------------------------------------------------------------- /server/src/env.ts: -------------------------------------------------------------------------------- 1 | import { Validator, oneOf, string, url, validate } from "valienv"; 2 | 3 | const buffer: Validator = (value = "") => Buffer.from(value, "hex"); 4 | 5 | export const env = validate({ 6 | env: process.env, 7 | validators: { 8 | NODE_ENV: oneOf("development", "production", "test"), 9 | LOG_LEVEL: oneOf("fatal", "error", "warn", "info", "debug", "trace", "silent"), 10 | 11 | PARTNER_API_URL: url, 12 | PARTNER_ADMIN_API_URL: url, 13 | UNAUTHENTICATED_API_URL: url, 14 | 15 | OAUTH_SERVER_URL: url, 16 | OAUTH_CLIENT_ID: string, 17 | OAUTH_CLIENT_SECRET: string, 18 | 19 | TGGL_SERVER_KEY: string, 20 | 21 | COOKIE_KEY: buffer, 22 | 23 | BANKING_URL: url, 24 | ONBOARDING_URL: url, 25 | PAYMENT_URL: url, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/banking.md: -------------------------------------------------------------------------------- 1 | # Web Banking 2 | 3 | Use Swan's Web Banking source code to **create your own banking app** for your users. 4 | 5 | You can completely customize the **navigation** as well as your **branding**. 6 | 7 | Several **pages** are included by default: _History_, _Account_, _Transfer_, _Cards_, _Members_, and _Profile_. 8 | You can keep them all, remove some, or add your own—whatever you need. 9 | 10 | Finally, included are some **workarounds**. 11 | Let Swan know if you need other workarounds, or if you've found one that works well for your use case. 12 | 13 | :::info 14 | Swan recommends a 5-minute time-to-live (TTL) for Web Banking, after which the session discontinues automatically. 15 | If the window remains open, the session tokens refresh and the session stays live. 16 | ::: 17 | -------------------------------------------------------------------------------- /clients/onboarding/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | Onboarding - Swan 15 | 16 | 17 | 18 | 19 |
20 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /server/src/utils/tggl.ts: -------------------------------------------------------------------------------- 1 | import { TgglLocalClient } from "tggl-client"; 2 | import { match, P } from "ts-pattern"; 3 | import { env } from "../env"; 4 | 5 | const tgglClient = new TgglLocalClient({ 6 | apiKey: env.TGGL_SERVER_KEY, 7 | }); 8 | 9 | export const getTgglClient = (projectId: string) => { 10 | return tgglClient.createClientForContext({ 11 | environment: match({ 12 | url: env.BANKING_URL, 13 | }) 14 | .with({ url: P.string.includes("local") }, () => "development") 15 | .with({ url: P.string.includes("master") }, () => "master") 16 | .with({ url: P.string.includes("preprod") }, () => "preprod") 17 | .otherwise(() => "prod"), 18 | environmentType: 19 | process.env.SWAN_ENVIRONMENT ?? 20 | (env.OAUTH_CLIENT_ID.startsWith("LIVE_") ? "live" : "sandbox"), 21 | projectId, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/DocumentationLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link, LinkProps } from "@swan-io/lake/src/components/Link"; 2 | import { Except } from "type-fest"; 3 | import { TranslationKey, t } from "../utils/i18n"; 4 | 5 | type ArticleName = "companyAvailableCountries" | "individualAvailableCountries" | "uboDetails"; 6 | 7 | const links: Record = { 8 | companyAvailableCountries: "supportLink.companyAvailableCountries", 9 | individualAvailableCountries: "supportLink.individualAvailableCountries", 10 | uboDetails: "supportLink.uboDetails", 11 | }; 12 | 13 | type Props = Except & { 14 | page: keyof typeof links; 15 | }; 16 | 17 | export const DocumentationLink = ({ page, ...props }: Props) => { 18 | const to = t(links[page]); 19 | return ; 20 | }; 21 | -------------------------------------------------------------------------------- /clients/banking/src/utils/projectId.ts: -------------------------------------------------------------------------------- 1 | import { Option, Result } from "@swan-io/boxed"; 2 | 3 | export const projectConfiguration = Result.fromExecution(() => { 4 | if (typeof __env.SWAN_PROJECT_ID === "string") { 5 | return __env.SWAN_PROJECT_ID; 6 | } else { 7 | throw new Error(); 8 | } 9 | }) 10 | .map( 11 | projectId => 12 | ({ 13 | mode: "SingleProject", 14 | projectId, 15 | }) as const, 16 | ) 17 | .flatMapError(() => { 18 | const [, projects, projectId] = location.pathname.split("/"); 19 | if (projects === "projects" && typeof projectId === "string") { 20 | return Result.Ok({ 21 | mode: "MultiProject", 22 | projectId, 23 | } as const); 24 | } 25 | return Result.Error(new Error("No project specified")); 26 | }) 27 | .toOption() 28 | .flatMap(nullable => Option.fromNullable(nullable)); 29 | -------------------------------------------------------------------------------- /clients/payment/src/utils/projectId.ts: -------------------------------------------------------------------------------- 1 | import { Option, Result } from "@swan-io/boxed"; 2 | 3 | export const projectConfiguration = Result.fromExecution(() => { 4 | if (typeof __env.SWAN_PROJECT_ID === "string") { 5 | return __env.SWAN_PROJECT_ID; 6 | } else { 7 | throw new Error(); 8 | } 9 | }) 10 | .map( 11 | projectId => 12 | ({ 13 | mode: "SingleProject", 14 | projectId, 15 | }) as const, 16 | ) 17 | .flatMapError(() => { 18 | const [, projects, projectId] = location.pathname.split("/"); 19 | if (projects === "projects" && typeof projectId === "string") { 20 | return Result.Ok({ 21 | mode: "MultiProject", 22 | projectId, 23 | } as const); 24 | } 25 | return Result.Error(new Error("No project specified")); 26 | }) 27 | .toOption() 28 | .flatMap(nullable => Option.fromNullable(nullable)); 29 | -------------------------------------------------------------------------------- /clients/onboarding/src/utils/projectId.ts: -------------------------------------------------------------------------------- 1 | import { Option, Result } from "@swan-io/boxed"; 2 | 3 | export const projectConfiguration = Result.fromExecution(() => { 4 | if (typeof __env.SWAN_PROJECT_ID === "string") { 5 | return __env.SWAN_PROJECT_ID; 6 | } else { 7 | throw new Error(); 8 | } 9 | }) 10 | .map( 11 | projectId => 12 | ({ 13 | mode: "SingleProject", 14 | projectId, 15 | }) as const, 16 | ) 17 | .flatMapError(() => { 18 | const [, projects, projectId] = location.pathname.split("/"); 19 | if (projects === "projects" && typeof projectId === "string") { 20 | return Result.Ok({ 21 | mode: "MultiProject", 22 | projectId, 23 | } as const); 24 | } 25 | return Result.Error(new Error("No project specified")); 26 | }) 27 | .toOption() 28 | .flatMap(nullable => Option.fromNullable(nullable)); 29 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | pnpm install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | pnpm start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | pnpm build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true pnpm deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /tests/utils/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import path from "pathe"; 3 | import { boolean, string, url, validate } from "valienv"; 4 | 5 | dotenv.config({ 6 | path: path.resolve(__dirname, "../../.env.e2e"), 7 | }); 8 | 9 | export const env = validate({ 10 | env: { 11 | ...process.env, 12 | CI: String(process.env.CI === "true"), 13 | }, 14 | validators: { 15 | CI: boolean, 16 | 17 | PARTNER_ADMIN_API_URL: url, 18 | PARTNER_API_URL: url, 19 | 20 | OAUTH_SERVER_URL: url, 21 | OAUTH_CLIENT_ID: string, 22 | OAUTH_CLIENT_SECRET: string, 23 | 24 | BANKING_URL: url, 25 | ONBOARDING_URL: url, 26 | PAYMENT_URL: url, 27 | 28 | TEST_KEY: string, 29 | 30 | PHONE_NUMBER: string, 31 | PASSCODE: string, 32 | 33 | TWILIO_ACCOUNT_ID: string, 34 | TWILIO_AUTH_TOKEN: string, 35 | WEBHOOK_SITE_API_KEY: string, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["clients", "scripts", "server", "tests"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "target": "ES2019", 7 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 8 | "jsx": "react-jsx", 9 | "moduleResolution": "Node", 10 | 11 | "allowJs": false, 12 | "strict": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "incremental": false, 18 | "esModuleInterop": true, 19 | 20 | "allowSyntheticDefaultImports": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedIndexedAccess": true, 23 | "forceConsistentCasingInFileNames": false, 24 | 25 | "typeRoots": ["types", "node_modules/@types"], 26 | 27 | // Keep this property (used by local lake development) 28 | "paths": {} 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/TrackComponent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, Ref, cloneElement } from "react"; 2 | import { sendMatomoEvent, useTrackingCategory } from "../utils/matomo"; 3 | 4 | type Props = { 5 | ref?: Ref; 6 | action: string; 7 | hook: string; 8 | children: ReactElement<{ 9 | ref?: unknown; 10 | [key: string]: unknown; 11 | }>; 12 | }; 13 | 14 | export const TrackComponent = ({ ref, action, hook, children }: Props) => { 15 | const { props } = children; 16 | const prop = props[hook]; 17 | const category = useTrackingCategory(); 18 | 19 | return cloneElement(children, { 20 | ref, 21 | 22 | ...(typeof prop === "function" && { 23 | [hook]: (...args: unknown[]) => { 24 | sendMatomoEvent({ type: "Action", category, name: action }); 25 | (prop as (...args: unknown[]) => void)(...args); 26 | }, 27 | }), 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/branding.md: -------------------------------------------------------------------------------- 1 | # Branding 2 | 3 | Web Banking uses the branding (logo, name, and accent color) defined at the project level. 4 | Additionally, you can modify your instance of the open source code to customize fonts and more. 5 | 6 | You can retrieve the **logo**, **name**, and **accent color** with the following fragment: 7 | 8 | ```graphql 9 | fragment ProjectBranding on ProjectInfo { 10 | id 11 | accentColor 12 | name 13 | logoUri 14 | } 15 | ``` 16 | 17 | If logged in, use the **Partner API** and a **user access token** to run the following query: 18 | 19 | ```graphql 20 | query { 21 | projectInfo { 22 | ...ProjectBranding 23 | } 24 | } 25 | ``` 26 | 27 | If logged out, use the **Unauthenticated API** to run the following query: 28 | 29 | ```graphql 30 | query { 31 | projectInfo(id: $projectId, env: $env) { 32 | ...ProjectBranding 33 | } 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "module": "CommonJS", 6 | "target": "ES2019", 7 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 8 | "outDir": "dist", 9 | "moduleResolution": "Node", 10 | 11 | "allowJs": false, 12 | "strict": true, 13 | "isolatedModules": true, 14 | "noEmit": false, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "incremental": false, 18 | "esModuleInterop": true, 19 | 20 | "declaration": false, 21 | "noEmitOnError": true, 22 | "pretty": true, 23 | "sourceMap": true, 24 | 25 | "allowSyntheticDefaultImports": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "noUncheckedIndexedAccess": true, 28 | "forceConsistentCasingInFileNames": false, 29 | 30 | "typeRoots": ["types", "node_modules/@types"] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /clients/payment/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | Payment - Swan 15 | 16 | 17 | 18 | 19 |
20 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/docs/deploy-as-is.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploy as is on your infra 3 | sidebar_label: Deploy as is on your infra 4 | --- 5 | 6 | You might want to deploy the onboarding & banking interfaces on your own subdomains, without having to fork the repository. For such use cases, we provide a public, up-to-date docker image: [`swan-io/swan-partner-frontend` on the GitHub registry](https://github.com/swan-io/swan-partner-frontend/pkgs/container/swan-partner-frontend). 7 | 8 | ```console 9 | $ docker pull ghcr.io/swan-io/swan-partner-frontend:latest 10 | ``` 11 | 12 | The image is published each time a new version hits production at Swan. 13 | 14 | :::note 15 | Try to keep the image up-to-date to remain compatible with changes to the Swan API and benefit from its new features and improvements. 16 | ::: 17 | 18 | You can check [the required environment variables and recommendations](/build-deploy#required-environment-variables). 19 | -------------------------------------------------------------------------------- /.env.e2e.example: -------------------------------------------------------------------------------- 1 | # Env 2 | NODE_ENV="test" 3 | LOG_LEVEL="error" 4 | 5 | # APIs 6 | PARTNER_ADMIN_API_URL="https://api.swan.io/sandbox-partner-admin/graphql" 7 | PARTNER_API_URL="https://api.swan.io/sandbox-partner/graphql" 8 | UNAUTHENTICATED_API_URL="https://api.swan.io/sandbox-unauthenticated/graphql" 9 | 10 | OAUTH_SERVER_URL="https://oauth.swan.io" 11 | OAUTH_CLIENT_ID="" # Your E2E project client ID 12 | OAUTH_CLIENT_SECRET="" # Your E2E project client secret 13 | 14 | # Key to encrypt cookies 15 | COOKIE_KEY="" # Your cookie key 16 | 17 | # URLs to expose your interfaces on 18 | BANKING_URL="http://banking.swan.localhost:8080" 19 | ONBOARDING_URL="http://onboarding.swan.localhost:8080" 20 | PAYMENT_URL="https://payment.swan.localhost:8080" 21 | 22 | TEST_KEY="" 23 | 24 | # You E2E user credentials 25 | PHONE_NUMBER="" 26 | PASSCODE="" 27 | 28 | TWILIO_ACCOUNT_ID="" 29 | TWILIO_AUTH_TOKEN="" 30 | WEBHOOK_SITE_API_KEY="" 31 | -------------------------------------------------------------------------------- /clients/banking/src/assets/images/physical-card-placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clients/banking/src/components/LakeCopyTextLine.tsx: -------------------------------------------------------------------------------- 1 | import { LakeCopyButton } from "@swan-io/lake/src/components/LakeCopyButton"; 2 | import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel"; 3 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 4 | import { colors } from "@swan-io/lake/src/constants/design"; 5 | import { t } from "../utils/i18n"; 6 | 7 | type Props = { 8 | accented?: boolean; 9 | label: string; 10 | text: string; 11 | }; 12 | 13 | export const LakeCopyTextLine = ({ accented = false, label, text }: Props) => ( 14 | {text}} 18 | actions={ 19 | 24 | } 25 | /> 26 | ); 27 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Env 2 | NODE_ENV="development" 3 | LOG_LEVEL="error" 4 | 5 | # APIs 6 | PARTNER_ADMIN_API_URL="https://api.swan.io/sandbox-partner-admin/graphql" 7 | PARTNER_API_URL="https://api.swan.io/sandbox-partner/graphql" 8 | UNAUTHENTICATED_API_URL="https://api.swan.io/sandbox-unauthenticated/graphql" 9 | 10 | OAUTH_SERVER_URL="https://oauth.swan.io" 11 | OAUTH_CLIENT_ID="" # Your client ID 12 | OAUTH_CLIENT_SECRET="" # Your client secret 13 | 14 | # Key to encrypt cookies 15 | COOKIE_KEY="" # Your cookie key 16 | 17 | # URLs to expose your interfaces on 18 | BANKING_URL="https://banking.swan.local:8080" 19 | ONBOARDING_URL="https://onboarding.swan.local:8080" 20 | PAYMENT_URL="https://payment.swan.local:8080" 21 | 22 | # Extra keys for the client 23 | CLIENT_PLACEKIT_API_KEY="" # Your placekit API key 24 | CLIENT_ONBOARDING_MATOMO_SITE_ID="" 25 | 26 | # Tggl keys 27 | TGGL_API_KEY="" 28 | 29 | # API key for automated translations 30 | OPENAI_API_KEY="" 31 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/OnboardingStepContent.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; 2 | import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; 3 | import { ReactNode } from "react"; 4 | import { StyleSheet, View } from "react-native"; 5 | 6 | const styles = StyleSheet.create({ 7 | content: { 8 | width: "100%", 9 | maxWidth: 1280, 10 | margin: "auto", 11 | paddingHorizontal: 24, 12 | flex: 1, 13 | }, 14 | contentDesktop: { 15 | paddingHorizontal: 40, 16 | }, 17 | }); 18 | 19 | type Props = { 20 | children: ReactNode; 21 | }; 22 | 23 | export const OnboardingStepContent = ({ children }: Props) => { 24 | return ( 25 | 26 | {({ large }) => ( 27 | {children} 28 | )} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /clients/payment/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import posthog, { SeverityLevel } from "posthog-js"; 2 | import { env } from "./env"; 3 | 4 | export const initPostHog = () => { 5 | if (import.meta.env.PROD && env.IS_SWAN_MODE) { 6 | const token = 7 | import.meta.env.DEV || 8 | env.PAYMENT_URL.includes("master") || 9 | env.PAYMENT_URL.includes("preprod") 10 | ? "phc_6u4uUv6Mp2a9mw7gOMEH8CiS4UEDlUHGuWlkz2OAYQe" 11 | : "phc_y7DlMezh1CgfrVIvkO2fkZMbJcbMziXCZvrPPWR2X8"; 12 | 13 | posthog.init(token, { 14 | api_host: "https://eu.i.posthog.com", 15 | defaults: "2025-05-24", 16 | }); 17 | } 18 | }; 19 | 20 | type Context = Partial<{ 21 | level: SeverityLevel; 22 | tags: Record; 23 | extra: Record; 24 | }>; 25 | 26 | export const logFrontendError = (exception: Error, { extra, level, tags }: Context = {}) => { 27 | posthog.captureException(exception, { extra, level, tags }); 28 | }; 29 | -------------------------------------------------------------------------------- /server/src/views/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | An error occured 6 | 32 | 33 | 34 |

An error occured

35 |

36 | Debug ID: 37 | {{REQUEST_ID}} 38 |

39 | 40 | -------------------------------------------------------------------------------- /clients/banking/src/components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { colors, ColorVariants } from "@swan-io/lake/src/constants/design"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | const styles = StyleSheet.create({ 5 | container: { 6 | flexDirection: "row", 7 | }, 8 | space: { 9 | width: 2, 10 | }, 11 | bar: { 12 | height: 6, 13 | borderRadius: 3, 14 | }, 15 | }); 16 | 17 | type Props = { 18 | min: number; 19 | max: number; 20 | value: number; 21 | color: ColorVariants; 22 | }; 23 | 24 | export const ProgressBar = ({ min, max, value, color }: Props) => { 25 | const percentage = (value - min) / (max - min); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/changeAdmin/ChangeAdminContext1.tsx: -------------------------------------------------------------------------------- 1 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 2 | import { OnboardingFooter } from "../../components/OnboardingFooter"; 3 | import { OnboardingStepContent } from "../../components/OnboardingStepContent"; 4 | import { PartnershipFooter } from "../../components/PartnershipFooter"; 5 | import { ChangeAdminRoute, Router } from "../../utils/routes"; 6 | 7 | type Props = { 8 | changeAdminRequestId: string; 9 | nextStep: ChangeAdminRoute; 10 | }; 11 | 12 | export const ChangeAdminContext1 = ({ changeAdminRequestId, nextStep }: Props) => { 13 | const onPressNext = () => { 14 | Router.push(nextStep, { requestId: changeAdminRequestId }); 15 | }; 16 | 17 | return ( 18 | 19 | ChangeAdminContext1 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /server/src/views/auth-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Error: {{description}} 6 | 35 | 36 | 37 |

{{description}}

38 |

You can close this page

39 | 40 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/PartnershipFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Fill } from "@swan-io/lake/src/components/Fill"; 2 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 3 | import { Space } from "@swan-io/lake/src/components/Space"; 4 | import { SwanLogo } from "@swan-io/lake/src/components/SwanLogo"; 5 | import { StyleSheet } from "react-native"; 6 | import { t } from "../utils/i18n"; 7 | 8 | const styles = StyleSheet.create({ 9 | partnership: { 10 | marginHorizontal: "auto", 11 | display: "flex", 12 | flexDirection: "row", 13 | alignItems: "baseline", 14 | }, 15 | swanPartnershipLogo: { 16 | marginLeft: 4, 17 | height: 9, 18 | }, 19 | }); 20 | 21 | export const PartnershipFooter = () => { 22 | return ( 23 | <> 24 | 25 | 26 | {t("wizard.partnership")} 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /scripts/env/writeEnvInterface.ts: -------------------------------------------------------------------------------- 1 | import { Array, Option } from "@swan-io/boxed"; 2 | import { parse } from "dotenv"; 3 | import fs from "node:fs"; 4 | import path from "pathe"; 5 | 6 | const environmentVariables = Object.keys( 7 | parse(fs.readFileSync(path.join(process.cwd(), ".env"), "utf-8")), 8 | ); 9 | 10 | const clientEnvironmentVariables = Array.filterMap(environmentVariables, key => 11 | key.startsWith("CLIENT_") ? Option.Some(key) : Option.None(), 12 | ); 13 | 14 | const file = `declare const __env: { 15 | // Server provided 16 | VERSION: string; 17 | SWAN_PROJECT_ID?: string; 18 | TGGL_API_KEY?: string; 19 | SWAN_ENVIRONMENT: "SANDBOX" | "LIVE"; 20 | ACCOUNT_MEMBERSHIP_INVITATION_MODE: "LINK" | "EMAIL"; 21 | BANKING_URL: string; 22 | PAYMENT_URL: string; 23 | IS_SWAN_MODE: boolean; 24 | // Client 25 | ${clientEnvironmentVariables.map(variableName => `${variableName}: string;`).join("\n ")} 26 | }; 27 | `; 28 | 29 | fs.writeFileSync(path.join(process.cwd(), "types/env/index.d.ts"), file, "utf-8"); 30 | -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/_finalize.mdx: -------------------------------------------------------------------------------- 1 | When a user clicks **Finalize**, several events occur. 2 | 3 | 1. The user is instructed to get their mobile device. 4 | 1. Swan checks for potential onboarding errors using `statusInfo`. 5 | 1. The user is redircted to the OAuth2 server (if there are no issues with `statusInfo`). 6 | 1. The user completes steps to enroll in the Swan constent app. 7 | 8 | The OAuth2 link must have the following query parameters: 9 | 10 | | Parameter | Description | 11 | | --------------------- | ---------------------------------------------------------------------------------------------------------------------- | 12 | | `identificationLevel` | Must match the value provided with your [query paramaters](../../oauth2.md#get-authlogin) (`Expert`, `PVID`, or `QES`) | 13 | | `onboardingId` | Link the user's [onboarding ID](./onboarding.md#onboarding-id) | 14 | -------------------------------------------------------------------------------- /scripts/locales/sort.ts: -------------------------------------------------------------------------------- 1 | import glob from "fast-glob"; 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | 5 | const isStringRecord = (value: unknown): value is Record => 6 | String(value) === "[object Object]" && 7 | value != null && 8 | Object.values(value).every(item => typeof item === "string"); 9 | 10 | // Arg automatically provided by lint-staged 11 | const filePaths = 12 | process.argv[2] == null ? glob.sync("clients/**/src/locales/*.json") : [process.argv[2]]; 13 | 14 | filePaths.forEach(filePath => { 15 | const content = fs.readFileSync(filePath, "utf-8"); 16 | const json: unknown = JSON.parse(content); 17 | 18 | if (!isStringRecord(json)) { 19 | throw new Error(`Invalid JSON: ${filePath}`); 20 | } 21 | 22 | const sorted = Object.keys(json) 23 | .sort() 24 | .reduce>((acc, key) => ({ ...acc, [key]: json[key] as string }), {}); 25 | 26 | fs.writeFileSync(filePath, JSON.stringify(sorted, null, 2) + os.EOL, "utf-8"); 27 | console.log(`Sorted ${filePath}`); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createIntl, createIntlCache } from "@formatjs/intl"; 2 | import sharedEN from "@swan-io/shared-business/src/locales/en.json"; 3 | import bankingEN from "../../clients/banking/src/locales/en.json"; 4 | import onboardingEN from "../../clients/onboarding/src/locales/en.json"; 5 | import { mapKeys } from "./functions"; 6 | 7 | const LANGUAGE_FALLBACK = "en"; 8 | 9 | const translationEN = { 10 | ...mapKeys("banking", bankingEN), 11 | ...mapKeys("onboarding", onboardingEN), 12 | ...mapKeys("shared", sharedEN), 13 | }; 14 | 15 | type TranslationKey = keyof typeof translationEN; 16 | type TranslationParams = Record; 17 | 18 | const intl = createIntl( 19 | { 20 | defaultLocale: LANGUAGE_FALLBACK, 21 | fallbackOnEmptyString: false, 22 | locale: LANGUAGE_FALLBACK, 23 | messages: translationEN, 24 | }, 25 | createIntlCache(), 26 | ); 27 | 28 | export const t = (key: TranslationKey, params?: TranslationParams): string => 29 | intl.formatMessage({ id: key, defaultMessage: translationEN[key] }, params).toString(); 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read # This is required for actions/checkout 13 | id-token: write # This is required for requesting the JWT 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: pnpm/action-setup@v4 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | cache: pnpm 23 | 24 | - name: Deploy prod 25 | if: ${{ github.repository == 'swan-io/swan-partner-frontend' }} 26 | run: pnpm deploy-ci 27 | env: 28 | TAG: prod-${{ github.ref_name }} 29 | DEPLOY_SWAN_TOKEN: ${{ secrets.DEPLOY_SWAN_TOKEN }} 30 | DEPLOY_SWAN_REPOSITORY: ${{ secrets.DEPLOY_SWAN_REPOSITORY }} 31 | DEPLOY_GIT_USER: ${{ secrets.DEPLOY_GIT_USER }} 32 | DEPLOY_GIT_EMAIL: ${{ secrets.DEPLOY_GIT_EMAIL }} 33 | DEPLOY_ENVIRONMENT: prod 34 | DEPLOY_APP_NAME: partner-frontend 35 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/changeAdmin/ChangeAdminConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 2 | import { OnboardingFooter } from "../../components/OnboardingFooter"; 3 | import { OnboardingStepContent } from "../../components/OnboardingStepContent"; 4 | import { PartnershipFooter } from "../../components/PartnershipFooter"; 5 | import { ChangeAdminRoute, Router } from "../../utils/routes"; 6 | 7 | type Props = { 8 | changeAdminRequestId: string; 9 | previousStep: ChangeAdminRoute; 10 | }; 11 | 12 | export const ChangeAdminConfirm = ({ changeAdminRequestId, previousStep }: Props) => { 13 | const onPressPrevious = () => { 14 | Router.push(previousStep, { requestId: changeAdminRequestId }); 15 | }; 16 | 17 | const onSubmit = () => { 18 | console.log("Submit change admin request"); 19 | }; 20 | 21 | return ( 22 | 23 | ChangeAdminConfirm 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Swan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /scripts/changelog/getChangelog.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import slackifyMarkdown from "slackify-markdown"; 4 | 5 | if (process.env.NOTIFY_GH_TOKEN == null) { 6 | process.exit(0); 7 | } 8 | 9 | const context = github.context; 10 | // Get authenticated GitHub client (Ocktokit): https://github.com/actions/toolkit/tree/master/packages/github#usage 11 | const octokit = github.getOctokit(process.env.NOTIFY_GH_TOKEN); 12 | 13 | // Get owner and repo from context of payload that triggered the action 14 | const { owner, repo } = context.repo; 15 | 16 | const tag = process.env.RELEASE_TAG?.replace("preprod-", "")?.replace("prod-", ""); 17 | 18 | if (tag == null) { 19 | process.exit(1); 20 | } 21 | 22 | const main = async () => { 23 | const release = await octokit.rest.repos.getReleaseByTag({ 24 | owner, 25 | repo, 26 | tag, 27 | }); 28 | 29 | const body = release.data.body; 30 | if (body == null) { 31 | process.exit(1); 32 | } 33 | 34 | core.setOutput("body", slackifyMarkdown(body).replace("*What's Changed*\n\n", "")); 35 | }; 36 | 37 | void main(); 38 | -------------------------------------------------------------------------------- /clients/banking/src/assets/images/logo-swan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /clients/onboarding/src/assets/imgs/logo-swan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/graphql/partner-admin.gql: -------------------------------------------------------------------------------- 1 | mutation CreateSandboxUser($firstName: String!, $lastName: String!) { 2 | createSandboxUser( 3 | input: { 4 | autoConsent: true 5 | birthDate: "1970-01-01" 6 | firstName: $firstName 7 | lastName: $lastName 8 | nationalityCCA3: "FRA" 9 | } 10 | ) { 11 | ... on CreateSandboxUserSuccessPayload { 12 | sandboxUser { 13 | id 14 | } 15 | } 16 | } 17 | } 18 | 19 | mutation CreateSandboxIdentification($userId: String!) { 20 | createSandboxIdentification( 21 | input: { userId: $userId, process: PVID, levels: { expert: Valid, pvid: Valid } } 22 | ) { 23 | ... on CreateSandboxIdentificationSuccessPayload { 24 | sandboxIdentification { 25 | id 26 | } 27 | } 28 | } 29 | } 30 | 31 | mutation EndorseSandboxUser($id: String!) { 32 | endorseSandboxUser(input: { id: $id }) { 33 | ... on EndorseSandboxUserSuccessPayload { 34 | sandboxUser { 35 | id 36 | } 37 | } 38 | } 39 | } 40 | 41 | mutation UpdateAccountHolder($input: UpdateAccountHolderInput!) { 42 | updateAccountHolder(input: $input) { 43 | __typename 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/changeAdmin/ChangeAdminContext2.tsx: -------------------------------------------------------------------------------- 1 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 2 | import { OnboardingFooter } from "../../components/OnboardingFooter"; 3 | import { OnboardingStepContent } from "../../components/OnboardingStepContent"; 4 | import { PartnershipFooter } from "../../components/PartnershipFooter"; 5 | import { ChangeAdminRoute, Router } from "../../utils/routes"; 6 | 7 | type Props = { 8 | changeAdminRequestId: string; 9 | previousStep: ChangeAdminRoute; 10 | nextStep: ChangeAdminRoute; 11 | }; 12 | 13 | export const ChangeAdminContext2 = ({ changeAdminRequestId, previousStep, nextStep }: Props) => { 14 | const onPressPrevious = () => { 15 | Router.push(previousStep, { requestId: changeAdminRequestId }); 16 | }; 17 | 18 | const onPressNext = () => { 19 | Router.push(nextStep, { requestId: changeAdminRequestId }); 20 | }; 21 | 22 | return ( 23 | 24 | ChangeAdminContext2 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/changeAdmin/ChangeAdminNewAdmin.tsx: -------------------------------------------------------------------------------- 1 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 2 | import { OnboardingFooter } from "../../components/OnboardingFooter"; 3 | import { OnboardingStepContent } from "../../components/OnboardingStepContent"; 4 | import { PartnershipFooter } from "../../components/PartnershipFooter"; 5 | import { ChangeAdminRoute, Router } from "../../utils/routes"; 6 | 7 | type Props = { 8 | changeAdminRequestId: string; 9 | previousStep: ChangeAdminRoute; 10 | nextStep: ChangeAdminRoute; 11 | }; 12 | 13 | export const ChangeAdminNewAdmin = ({ changeAdminRequestId, previousStep, nextStep }: Props) => { 14 | const onPressPrevious = () => { 15 | Router.push(previousStep, { requestId: changeAdminRequestId }); 16 | }; 17 | 18 | const onPressNext = () => { 19 | Router.push(nextStep, { requestId: changeAdminRequestId }); 20 | }; 21 | 22 | return ( 23 | 24 | ChangeAdminNewAdmin 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/changeAdmin/ChangeAdminDocuments.tsx: -------------------------------------------------------------------------------- 1 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 2 | import { OnboardingFooter } from "../../components/OnboardingFooter"; 3 | import { OnboardingStepContent } from "../../components/OnboardingStepContent"; 4 | import { PartnershipFooter } from "../../components/PartnershipFooter"; 5 | import { ChangeAdminRoute, Router } from "../../utils/routes"; 6 | 7 | type Props = { 8 | changeAdminRequestId: string; 9 | previousStep: ChangeAdminRoute; 10 | nextStep: ChangeAdminRoute; 11 | }; 12 | 13 | export const ChangeAdminDocuments = ({ changeAdminRequestId, previousStep, nextStep }: Props) => { 14 | const onPressPrevious = () => { 15 | Router.push(previousStep, { requestId: changeAdminRequestId }); 16 | }; 17 | 18 | const onPressNext = () => { 19 | Router.push(nextStep, { requestId: changeAdminRequestId }); 20 | }; 21 | 22 | return ( 23 | 24 | ChangeAdminDocuments 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/changeAdmin/ChangeAdminRequester.tsx: -------------------------------------------------------------------------------- 1 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 2 | import { OnboardingFooter } from "../../components/OnboardingFooter"; 3 | import { OnboardingStepContent } from "../../components/OnboardingStepContent"; 4 | import { PartnershipFooter } from "../../components/PartnershipFooter"; 5 | import { ChangeAdminRoute, Router } from "../../utils/routes"; 6 | 7 | type Props = { 8 | changeAdminRequestId: string; 9 | previousStep: ChangeAdminRoute; 10 | nextStep: ChangeAdminRoute; 11 | }; 12 | 13 | export const ChangeAdminRequester = ({ changeAdminRequestId, previousStep, nextStep }: Props) => { 14 | const onPressPrevious = () => { 15 | Router.push(previousStep, { requestId: changeAdminRequestId }); 16 | }; 17 | 18 | const onPressNext = () => { 19 | Router.push(nextStep, { requestId: changeAdminRequestId }); 20 | }; 21 | 22 | return ( 23 | 24 | ChangeAdminRequester 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /clients/banking/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Banking - Swan 18 | 19 | 20 | 21 |
22 |
23 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/docs/specs/onboarding/onboarding.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Onboarding 3 | --- 4 | 5 | The user onboarding process is, at its base, a form. 6 | 7 | Users (especially your end customers) complete this form to **create their accounts**, providing you and Swan a streamlined process to **collect required information and documents**. 8 | 9 | ## Onboarding ID 10 | 11 | The onboarding interface uses the **unauthenticated API**. 12 | Because the user doesn't techincally exist within Swan, you must **create an onboarding**, which generates an **onboarding ID**. 13 | Until the user is authenticated, all information about their onboarding process is attached to their onboarding ID. 14 | 15 | ## Flows 16 | 17 | After fetching the onboarding, route to the correct onboarding flow based on the `onboarding.info` typename. 18 | 19 | - [Individual onboarding](/specs/onboarding/individual) 20 | - [Company onboarding](/specs/onboarding/company) 21 | 22 | ## Invalid `statusInfo` 23 | 24 | There can be validation discrepencies between client-side and server-side. 25 | To avoid blocking the user, you cannot finalize an onboarding if `statusInfo` is invalid. 26 | 27 | ![Screenshot of warning state for missing information in onboarding flow](./images/onboarding-validation.png) 28 | -------------------------------------------------------------------------------- /clients/banking/src/components/CopyTextButton.tsx: -------------------------------------------------------------------------------- 1 | import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; 2 | import { LakeTooltip } from "@swan-io/lake/src/components/LakeTooltip"; 3 | import { setClipboardText } from "@swan-io/lake/src/utils/clipboard"; 4 | import { useState } from "react"; 5 | import { t } from "../utils/i18n"; 6 | 7 | export const CopyTextButton = ({ 8 | value, 9 | disabled = false, 10 | }: { 11 | value: string; 12 | disabled?: boolean; 13 | }) => { 14 | const [visibleState, setVisibleState] = useState<"copy" | "copied">("copy"); 15 | return ( 16 | setVisibleState("copy")} 20 | togglableOnFocus={true} 21 | content={ 22 | visibleState === "copy" ? t("copyButton.copyTooltip") : t("copyButton.copiedTooltip") 23 | } 24 | > 25 | { 32 | setClipboardText(value); 33 | setVisibleState("copied"); 34 | }} 35 | /> 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #6240b5; 11 | --ifm-navbar-link-hover-color: #8166c4; 12 | --ifm-color-primary-dark: #4e3391; 13 | --ifm-color-primary-darker: #3b266d; 14 | --ifm-color-primary-darkest: #271a48; 15 | --ifm-color-primary-light: #a18cd3; 16 | --ifm-color-primary-lighter: #c0b3e1; 17 | --ifm-color-primary-lightest: #e0d9f0; 18 | --ifm-code-font-size: 90%; 19 | } 20 | 21 | html[data-theme="dark"] { 22 | --ifm-color-primary: #c0b3e1; 23 | } 24 | 25 | .docusaurus-highlight-code-line { 26 | background-color: rgba(0, 0, 0, 0.1); 27 | display: block; 28 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 29 | padding: 0 var(--ifm-pre-padding); 30 | } 31 | 32 | html[data-theme="dark"] .docusaurus-highlight-code-line { 33 | background-color: rgba(0, 0, 0, 0.3); 34 | } 35 | 36 | pre { 37 | line-height: 1.7; 38 | } 39 | 40 | .footer--dark { 41 | --ifm-footer-background-color: #14191a; 42 | } 43 | -------------------------------------------------------------------------------- /clients/payment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swan-io/payment", 3 | "version": "1.26.19", 4 | "private": true, 5 | "packageManager": "pnpm@10.26.1", 6 | "engines": { 7 | "node": "^24.12.0" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:swan-io/swan-partner-frontend.git" 12 | }, 13 | "scripts": { 14 | "prepack": "tsc --build", 15 | "clean": "tsc --clean" 16 | }, 17 | "dependencies": { 18 | "@formatjs/intl": "3.1.6", 19 | "@juggle/resize-observer": "3.4.0", 20 | "@swan-io/boxed": "3.2.1", 21 | "@swan-io/chicane": "3.0.0", 22 | "@swan-io/graphql-client": "0.8.0", 23 | "@swan-io/lake": "13.4.2", 24 | "@swan-io/shared-business": "13.4.2", 25 | "@swan-io/use-form": "3.1.0", 26 | "core-js": "3.45.1", 27 | "dayjs": "1.11.13", 28 | "frames-react": "1.2.2", 29 | "iban": "0.0.14", 30 | "nanoid": "5.1.5", 31 | "posthog-js": "1.266.2", 32 | "react": "19.1.1", 33 | "react-dom": "19.1.1", 34 | "react-native-web": "0.20.0", 35 | "ts-pattern": "5.8.0" 36 | }, 37 | "devDependencies": { 38 | "@types/iban": "0.0.35", 39 | "@types/react": "19.1.10", 40 | "@types/react-dom": "19.1.7", 41 | "@types/react-native": "0.72.8", 42 | "type-fest": "4.41.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /clients/onboarding/src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 2 | import { Box } from "@swan-io/lake/src/components/Box"; 3 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 4 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 5 | import { Space } from "@swan-io/lake/src/components/Space"; 6 | import { StyleProp, StyleSheet, ViewStyle } from "react-native"; 7 | import { t } from "../utils/i18n"; 8 | 9 | const styles = StyleSheet.create({ 10 | base: { 11 | flexGrow: 1, 12 | flexShrink: 1, 13 | }, 14 | }); 15 | 16 | type Props = { 17 | title?: string; 18 | text?: string; 19 | style?: StyleProp; 20 | }; 21 | 22 | export const NotFoundPage = ({ title = t("error.pageNotFound"), text = "", style }: Props) => ( 23 | 24 | 25 | 26 | 27 | 28 | {title} 29 | 30 | 31 | {text !== "" && ( 32 | <> 33 | 34 | {text} 35 | 36 | )} 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /clients/payment/src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 2 | import { Box } from "@swan-io/lake/src/components/Box"; 3 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 4 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 5 | import { Space } from "@swan-io/lake/src/components/Space"; 6 | import { StyleProp, StyleSheet, ViewStyle } from "react-native"; 7 | import { t } from "../utils/i18n"; 8 | 9 | const styles = StyleSheet.create({ 10 | base: { 11 | flexGrow: 1, 12 | flexShrink: 1, 13 | }, 14 | }); 15 | 16 | type Props = { 17 | title?: string; 18 | text?: string; 19 | style?: StyleProp; 20 | }; 21 | 22 | export const NotFoundPage = ({ title = t("error.pageNotFound"), text = "", style }: Props) => ( 23 | 24 | 25 | 26 | 27 | 28 | {title} 29 | 30 | 31 | {text !== "" && ( 32 | <> 33 | 34 | {text} 35 | 36 | )} 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /tests/4-profile.banking.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import IT from "../clients/banking/src/locales/it.json"; 3 | import { env } from "./utils/env"; 4 | import { t } from "./utils/i18n"; 5 | import { getSession } from "./utils/session"; 6 | 7 | test("Profile - info is present", async ({ page }) => { 8 | const { benady } = await getSession(); 9 | await page.goto(`${env.BANKING_URL}/${benady.memberships.individual.french.id}/profile`); 10 | 11 | const main = page.getByRole("main"); 12 | const section = main.locator("section").first(); 13 | await expect(section.getByRole("heading", { name: "Nicolas Benady" })).toBeAttached(); 14 | await expect(section.getByText(benady.email)).toBeAttached(); 15 | }); 16 | 17 | test("Profile - change language", async ({ page }) => { 18 | const { benady } = await getSession(); 19 | await page.goto(`${env.BANKING_URL}/${benady.memberships.individual.french.id}/profile`); 20 | 21 | const main = page.getByRole("main"); 22 | const select = main.getByLabel(t("banking.profile.language")); 23 | await select.click(); 24 | const options = page.getByRole("listbox"); 25 | await options.getByRole("option", { name: "Italiano" }).click(); 26 | await expect( 27 | main.getByRole("heading", { name: IT["profile.personalInformation"] }), 28 | ).toBeAttached(); 29 | }); 30 | -------------------------------------------------------------------------------- /server/src/graphql/unauthenticated.gql: -------------------------------------------------------------------------------- 1 | mutation OnboardIndividualAccountHolder( 2 | $input: UnauthenticatedOnboardPublicIndividualAccountHolderInput! 3 | ) { 4 | unauthenticatedOnboardPublicIndividualAccountHolder(input: $input) { 5 | ... on UnauthenticatedOnboardPublicIndividualAccountHolderSuccessPayload { 6 | __typename 7 | onboarding { 8 | id 9 | } 10 | } 11 | ... on PublicOnboardingDisabledRejection { 12 | __typename 13 | message 14 | } 15 | ... on ValidationRejection { 16 | __typename 17 | message 18 | } 19 | } 20 | } 21 | 22 | mutation OnboardCompanyAccountHolder( 23 | $input: UnauthenticatedOnboardPublicCompanyAccountHolderInput! 24 | ) { 25 | unauthenticatedOnboardPublicCompanyAccountHolder(input: $input) { 26 | ... on UnauthenticatedOnboardPublicCompanyAccountHolderSuccessPayload { 27 | __typename 28 | onboarding { 29 | id 30 | } 31 | } 32 | ... on PublicOnboardingDisabledRejection { 33 | __typename 34 | message 35 | } 36 | ... on ValidationRejection { 37 | __typename 38 | message 39 | } 40 | } 41 | } 42 | 43 | query GetOnboardingClientOAuth($onboardingId: ID!) { 44 | onboardingInfo(id: $onboardingId) { 45 | projectInfo { 46 | oAuthClientId 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /clients/onboarding/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swan-io/onboarding", 3 | "version": "1.26.19", 4 | "private": true, 5 | "packageManager": "pnpm@10.26.1", 6 | "engines": { 7 | "node": "^24.12.0" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:swan-io/swan-partner-frontend.git" 12 | }, 13 | "scripts": { 14 | "prepack": "tsc --build", 15 | "clean": "tsc --clean" 16 | }, 17 | "dependencies": { 18 | "@formatjs/intl": "3.1.6", 19 | "@juggle/resize-observer": "3.4.0", 20 | "@swan-io/boxed": "3.2.1", 21 | "@swan-io/chicane": "3.0.0", 22 | "@swan-io/graphql-client": "0.8.0", 23 | "@swan-io/lake": "13.4.2", 24 | "@swan-io/request": "3.1.0", 25 | "@swan-io/shared-business": "13.4.2", 26 | "@swan-io/use-form": "3.1.0", 27 | "core-js": "3.45.1", 28 | "dayjs": "1.11.13", 29 | "nanoid": "5.1.5", 30 | "posthog-js": "1.266.2", 31 | "react": "19.1.1", 32 | "react-atomic-state": "2.1.0", 33 | "react-dom": "19.1.1", 34 | "react-native-web": "0.20.0", 35 | "tggl-client": "2.1.2", 36 | "ts-pattern": "5.8.0", 37 | "uuid": "11.1.0" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "19.1.10", 41 | "@types/react-dom": "19.1.7", 42 | "@types/react-native": "0.72.8", 43 | "type-fest": "4.41.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/docs/user-sessions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: User sessions 3 | sidebar_label: User sessions 4 | --- 5 | 6 | The server uses [@fastify/secure-session](https://github.com/fastify/fastify-secure-session) to **store user session data** as an encrypted, secure, HTTP-only cookie. 7 | 8 | ## Setup 9 | 10 | This techniques requires a `COOKIE_KEY` environment variable. 11 | Generate one using the following command: 12 | 13 | ```console 14 | $ pnpm generate-cookie-key 15 | ``` 16 | 17 | :::warning 18 | Use a different `COOKIE_KEY` for each environment, and do not save it in your repository. 19 | ::: 20 | 21 | ## Contents 22 | 23 | In request handlers, access the session data using `request.session`: 24 | 25 | ```ts 26 | // get session data 27 | request.session.get("myKey"); 28 | // set session data 29 | request.session.set("myKey", "myValue"); 30 | ``` 31 | 32 | ## User access token 33 | 34 | For convenience, request a Swan user access token directly using `request.accessToken`: 35 | 36 | ```ts 37 | const accessToken = request.accessToken; 38 | 39 | // user isn't logged in 40 | if (accessToken == undefined) { 41 | return reply.status(401).send("Unauthorized"); 42 | } else { 43 | // do something with `accessToken` 44 | } 45 | ``` 46 | 47 | :::info 48 | Learn more about [Swan and access tokens](https://docs.swan.io/api/authentication) in our main docs. 49 | ::: 50 | -------------------------------------------------------------------------------- /clients/banking/src/utils/accountMembership.ts: -------------------------------------------------------------------------------- 1 | import { isNotEmpty } from "@swan-io/lake/src/utils/nullish"; 2 | import { match } from "ts-pattern"; 3 | 4 | type AccountMembership = { 5 | statusInfo: 6 | | { 7 | __typename: "AccountMembershipBindingUserErrorStatusInfo"; 8 | restrictedTo: { __typename: "RestrictedTo"; firstName: string; lastName: string }; 9 | } 10 | | { __typename: "AccountMembershipConsentPendingStatusInfo" } 11 | | { __typename: "AccountMembershipDisabledStatusInfo" } 12 | | { __typename: "AccountMembershipEnabledStatusInfo" } 13 | | { 14 | __typename: "AccountMembershipInvitationSentStatusInfo"; 15 | restrictedTo: { __typename: "RestrictedTo"; firstName: string; lastName: string }; 16 | } 17 | | { __typename: "AccountMembershipSuspendedStatusInfo" }; 18 | user?: { 19 | fullName?: string | null; 20 | } | null; 21 | }; 22 | 23 | export const getMemberName = ({ accountMembership }: { accountMembership: AccountMembership }) => { 24 | return match(accountMembership.statusInfo) 25 | .with( 26 | { __typename: "AccountMembershipBindingUserErrorStatusInfo" }, 27 | { __typename: "AccountMembershipInvitationSentStatusInfo" }, 28 | ({ restrictedTo }) => 29 | [restrictedTo.firstName, restrictedTo.lastName].filter(isNotEmpty).join(" "), 30 | ) 31 | .otherwise(() => accountMembership.user?.fullName ?? ""); 32 | }; 33 | -------------------------------------------------------------------------------- /scripts/tests/testSetup.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import customParseFormat from "dayjs/plugin/customParseFormat"; 3 | import localizedFormat from "dayjs/plugin/localizedFormat"; 4 | import relativeTime from "dayjs/plugin/relativeTime"; 5 | import utc from "dayjs/plugin/utc"; 6 | import dotenv from "dotenv"; 7 | import path from "pathe"; 8 | 9 | // @ts-expect-error 10 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 11 | 12 | // https://day.js.org/docs/en/plugin/plugin 13 | dayjs.extend(utc); 14 | dayjs.extend(customParseFormat); 15 | dayjs.extend(relativeTime); 16 | dayjs.extend(localizedFormat); 17 | 18 | const { parsed: env = {} } = dotenv.config({ 19 | path: path.resolve(__dirname, "../../.env.example"), 20 | }); 21 | 22 | Object.assign( 23 | globalThis, 24 | Object.keys(env).reduce( 25 | (acc, key) => ({ 26 | ...acc, 27 | [`__SWAN_ENV_${key}__`]: env[key], 28 | }), 29 | {}, 30 | ), 31 | ); 32 | 33 | // https://jestjs.io/docs/en/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 34 | Object.defineProperty(window, "matchMedia", { 35 | writable: true, 36 | value: (query: unknown) => ({ 37 | matches: false, 38 | media: query, 39 | onchange: null, 40 | addEventListener: () => {}, 41 | addListener: () => {}, // deprecated 42 | dispatchEvent: () => {}, 43 | removeEventListener: () => {}, 44 | removeListener: () => {}, // deprecated 45 | }), 46 | }); 47 | -------------------------------------------------------------------------------- /clients/banking/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swan-io/banking", 3 | "version": "1.26.19", 4 | "private": true, 5 | "packageManager": "pnpm@10.26.1", 6 | "engines": { 7 | "node": "^24.12.0" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:swan-io/swan-partner-frontend.git" 12 | }, 13 | "scripts": { 14 | "prepack": "tsc --build", 15 | "clean": "tsc --clean" 16 | }, 17 | "dependencies": { 18 | "@formatjs/intl": "3.1.6", 19 | "@juggle/resize-observer": "3.4.0", 20 | "@swan-io/boxed": "3.2.1", 21 | "@swan-io/chicane": "3.0.0", 22 | "@swan-io/graphql-client": "0.8.0", 23 | "@swan-io/lake": "13.4.2", 24 | "@swan-io/request": "3.1.0", 25 | "@swan-io/shared-business": "13.4.2", 26 | "@swan-io/use-form": "3.1.0", 27 | "core-js": "3.45.1", 28 | "dayjs": "1.11.13", 29 | "iban": "0.0.14", 30 | "libphonenumber-js": "1.12.13", 31 | "nanoid": "5.1.5", 32 | "posthog-js": "1.266.2", 33 | "react": "19.1.1", 34 | "react-atomic-state": "2.1.0", 35 | "react-dom": "19.1.1", 36 | "react-native-web": "0.20.0", 37 | "rifm": "0.12.1", 38 | "tggl-client": "2.1.2", 39 | "ts-pattern": "5.8.0" 40 | }, 41 | "devDependencies": { 42 | "@types/iban": "0.0.35", 43 | "@types/react": "19.1.10", 44 | "@types/react-dom": "19.1.7", 45 | "@types/react-native": "0.72.8", 46 | "type-fest": "4.41.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/Content/index.js: -------------------------------------------------------------------------------- 1 | import { ThemeClassNames } from "@docusaurus/theme-common"; 2 | import { useDoc } from "@docusaurus/theme-common/internal"; 3 | import Heading from "@theme/Heading"; 4 | import MDXContent from "@theme/MDXContent"; 5 | import clsx from "clsx"; 6 | /** 7 | Title can be declared inside md content or declared through 8 | front matter and added manually. To make both cases consistent, 9 | the added title is added under the same div.markdown block 10 | See https://github.com/facebook/docusaurus/pull/4882#issuecomment-853021120 11 | 12 | We render a "synthetic title" if: 13 | - user doesn't ask to hide it with front matter 14 | - the markdown content does not already contain a top-level h1 heading 15 | */ 16 | function useSyntheticTitle() { 17 | const { metadata, frontMatter, contentTitle } = useDoc(); 18 | const shouldRender = !frontMatter.hide_title && typeof contentTitle === "undefined"; 19 | if (!shouldRender) { 20 | return null; 21 | } 22 | return metadata.title; 23 | } 24 | export default function DocItemContent({ children }) { 25 | const syntheticTitle = useSyntheticTitle(); 26 | return ( 27 |
28 | {syntheticTitle && ( 29 |
30 | {syntheticTitle} 31 |
32 | )} 33 | {children} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/SupportingDocumentCollectionSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 2 | import { Box } from "@swan-io/lake/src/components/Box"; 3 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 4 | import { Space } from "@swan-io/lake/src/components/Space"; 5 | import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; 6 | import { colors, spacings } from "@swan-io/lake/src/constants/design"; 7 | import { StyleSheet } from "react-native"; 8 | import { t } from "../utils/i18n"; 9 | 10 | const styles = StyleSheet.create({ 11 | fill: { 12 | ...commonStyles.fill, 13 | }, 14 | title: { 15 | paddingBottom: spacings[4], 16 | }, 17 | subtitle: { 18 | lineHeight: 24, 19 | }, 20 | }); 21 | 22 | export const SupportingDocumentCollectionSuccessPage = () => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | {t("supportingDocumentCollection.success.title")} 30 | 31 | 32 | 33 | {t("supportingDocumentCollection.success.subtitle")} 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /clients/onboarding/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "@swan-io/lake/src/assets/fonts/Inter.css"; 2 | import "@swan-io/lake/src/assets/main.css"; 3 | import "./main.css"; 4 | 5 | import { ResizeObserver } from "@juggle/resize-observer"; 6 | import "core-js/proposals/array-flat-map"; 7 | import "core-js/proposals/change-array-by-copy-stage-4"; 8 | import "core-js/proposals/object-from-entries"; 9 | import "core-js/proposals/promise-all-settled"; 10 | import "core-js/proposals/relative-indexing-method"; 11 | import "core-js/proposals/string-replace-all-stage-4"; 12 | 13 | // overrides shared-business supported languages 14 | import "./utils/i18n"; 15 | 16 | import { isNullish } from "@swan-io/lake/src/utils/nullish"; 17 | import { AppRegistry } from "react-native"; 18 | import { App } from "./App"; 19 | import { initPostHog } from "./utils/logger"; 20 | 21 | initPostHog(); 22 | 23 | if (isNullish(window.ResizeObserver)) { 24 | window.ResizeObserver = ResizeObserver; 25 | } 26 | 27 | const rootTag = document.getElementById("app-root"); 28 | 29 | if (rootTag != null) { 30 | AppRegistry.registerComponent("App", () => App); 31 | AppRegistry.runApplication("App", { rootTag }); 32 | } 33 | 34 | console.log( 35 | `%c👋 Hey, looks like you're curious about how Swan works! 36 | %c👀 Swan is looking for many curious people. 37 | 38 | %c➡️ Feel free to check out https://www.welcometothejungle.com/fr/companies/swan/jobs, or send a message to join-us@swan.io`, 39 | "font-size: 1.125em; font-weight: bold;", 40 | "font-size: 1.125em;", 41 | "font-size: 1.125em;", 42 | ); 43 | -------------------------------------------------------------------------------- /clients/payment/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "@swan-io/lake/src/assets/fonts/Inter.css"; 2 | import "@swan-io/lake/src/assets/main.css"; 3 | import "./main.css"; 4 | 5 | import { ResizeObserver } from "@juggle/resize-observer"; 6 | import "core-js/proposals/array-flat-map"; 7 | import "core-js/proposals/change-array-by-copy-stage-4"; 8 | import "core-js/proposals/object-from-entries"; 9 | import "core-js/proposals/promise-all-settled"; 10 | import "core-js/proposals/relative-indexing-method"; 11 | import "core-js/proposals/string-replace-all-stage-4"; 12 | 13 | // overrides shared-business supported languages 14 | import "./utils/i18n"; 15 | 16 | import { isNullish } from "@swan-io/lake/src/utils/nullish"; 17 | import { AppRegistry } from "react-native"; 18 | import { App } from "./App"; 19 | import { initPostHog } from "./utils/logger"; 20 | 21 | initPostHog(); 22 | 23 | if (isNullish(window.ResizeObserver)) { 24 | window.ResizeObserver = ResizeObserver; 25 | } 26 | 27 | const rootTag = document.getElementById("app-root"); 28 | 29 | if (rootTag != null) { 30 | AppRegistry.registerComponent("App", () => App); 31 | AppRegistry.runApplication("App", { rootTag }); 32 | } 33 | 34 | console.log( 35 | `%c👋 Hey, looks like you're curious about how Swan works! 36 | %c👀 Swan is looking for many curious people. 37 | 38 | %c➡️ Feel free to check out https://www.welcometothejungle.com/fr/companies/swan/jobs, or send a message to join-us@swan.io`, 39 | "font-size: 1.125em; font-weight: bold;", 40 | "font-size: 1.125em;", 41 | "font-size: 1.125em;", 42 | ); 43 | -------------------------------------------------------------------------------- /clients/banking/src/components/CardItemPhysicalChoosePinForm.tsx: -------------------------------------------------------------------------------- 1 | import { Option } from "@swan-io/boxed"; 2 | import { RadioGroup, RadioGroupItem } from "@swan-io/lake/src/components/RadioGroup"; 3 | import { useForm } from "@swan-io/use-form"; 4 | import { Ref, useImperativeHandle } from "react"; 5 | import { t } from "../utils/i18n"; 6 | 7 | export type CardItemPhysicalChoosePinFormRef = { 8 | submit: () => void; 9 | }; 10 | 11 | export type EditorState = { 12 | choosePin: boolean; 13 | }; 14 | 15 | type Props = { 16 | ref?: Ref; 17 | onSubmit: (editorState: EditorState) => void; 18 | }; 19 | 20 | const items: RadioGroupItem[] = [ 21 | { 22 | value: true, 23 | name: t("card.physicalCard.choosePin.yes"), 24 | }, 25 | { 26 | value: false, 27 | name: t("card.physicalCard.choosePin.no"), 28 | }, 29 | ]; 30 | 31 | export const CardItemPhysicalChoosePinForm = ({ ref, onSubmit }: Props) => { 32 | const { Field, submitForm } = useForm({ 33 | choosePin: { 34 | initialValue: true, 35 | }, 36 | }); 37 | 38 | useImperativeHandle(ref, () => ({ 39 | submit: () => { 40 | submitForm({ 41 | onSuccess: values => { 42 | Option.allFromDict(values).tapSome(onSubmit); 43 | }, 44 | }); 45 | }, 46 | })); 47 | 48 | return ( 49 | <> 50 | 51 | {({ value, onChange }) => ( 52 | 53 | )} 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /tests/utils/session.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import { PartialDeep } from "type-fest"; 3 | import { sessionPath } from "../../playwright.config"; 4 | import { deepMerge } from "./functions"; 5 | 6 | type Membership = { 7 | id: string; 8 | account: { 9 | id: string; 10 | IBAN: string; 11 | number: string; 12 | holder: { 13 | id: string; 14 | }; 15 | }; 16 | }; 17 | 18 | type Session = { 19 | project: { 20 | accessToken: string; 21 | }; 22 | user: { 23 | accessToken: string; 24 | refreshToken: string; 25 | }; 26 | benady: { 27 | id: string; 28 | email: string; 29 | 30 | memberships: { 31 | individual: { 32 | french: Membership; 33 | german: Membership; 34 | spanish: Membership; 35 | dutch: Membership; 36 | }; 37 | company: { 38 | french: Membership; 39 | german: Membership; 40 | spanish: Membership; 41 | dutch: Membership; 42 | }; 43 | }; 44 | }; 45 | saison: { 46 | id: string; 47 | email: string; 48 | }; 49 | }; 50 | 51 | export const getSession = async () => { 52 | const content = await fs.readFile(sessionPath, "utf-8"); 53 | return JSON.parse(content) as Session; 54 | }; 55 | 56 | export const saveSession = async (data: PartialDeep) => { 57 | const currentData = await getSession().catch(() => ({})); 58 | const mergedData = deepMerge(currentData, data); 59 | const content = JSON.stringify(mergedData, null, 2); 60 | return fs.writeFile(sessionPath, content, "utf-8"); 61 | }; 62 | -------------------------------------------------------------------------------- /docs/docs/graphql.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: GraphQL 3 | sidebar_label: GraphQL 4 | --- 5 | 6 | Swan exposes a [GraphQL](https://graphql.org/) API. 7 | Anyone can try it out on the [API Explorer](https://explorer.swan.io/) in Sandbox mode. 8 | 9 | ## Schemas 10 | 11 | Update GraphQL schemas with the following command: 12 | 13 | ```console 14 | $ pnpm graphql-update-schemas 15 | ``` 16 | 17 | :::info 18 | Versioned schemas are stored in the repository to maintain consistent Continuous Integration (CI). 19 | ::: 20 | 21 | ## Documents 22 | 23 | All required documents are in the `graphql` directory for each application. 24 | 25 | Replace `$consentId` with your consent ID. 26 | 27 | ```graphql title="clients/banking/src/graphql/partner.gql" 28 | query ConsentCallbackPage($consentId: ID!) { 29 | consent(id: $consentId) { 30 | id 31 | status 32 | } 33 | } 34 | 35 | # ... 36 | ``` 37 | 38 | ## Code generator 39 | 40 | In order to benefit from GraphQL's types, we use [GraphQL Codegen](https://the-guild.dev/graphql/codegen). 41 | 42 | Run codegen with the following command: 43 | 44 | ```console 45 | $ pnpm graphql-codegen 46 | ``` 47 | 48 | In this example, `codegen` generates a new file `partner.ts`, housed with documents, which we can import: 49 | 50 | ```ts 51 | import { ConsentCallbackPageDocument } from "../graphql/partner"; 52 | 53 | const MyComponent = () => { 54 | const [{ data }] = useQuery(ConsentCallbackPageDocument, {}); 55 | // `data` is a typed object 56 | // ... 57 | }; 58 | ``` 59 | 60 | :::info 61 | Generated files are **not versioned** to avoid unnecessary conflicts. Instead, they're generated with CI. 62 | ::: 63 | -------------------------------------------------------------------------------- /clients/banking/src/components/FoldableAlert.tsx: -------------------------------------------------------------------------------- 1 | import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; 2 | import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; 3 | import { ColorVariants } from "@swan-io/lake/src/constants/design"; 4 | import { useDisclosure } from "@swan-io/lake/src/hooks/useDisclosure"; 5 | import { ComponentProps, ReactNode } from "react"; 6 | import { match } from "ts-pattern"; 7 | import { t } from "../utils/i18n"; 8 | 9 | type FoldableAlertProps = { 10 | variant: ComponentProps["variant"]; 11 | title: string; 12 | more: ReactNode; 13 | openedAtStart?: boolean; 14 | }; 15 | 16 | export const FoldableAlert = ({ 17 | variant, 18 | title, 19 | more, 20 | openedAtStart = false, 21 | }: FoldableAlertProps) => { 22 | const [visible, { toggle }] = useDisclosure(openedAtStart); 23 | 24 | return ( 25 | () 36 | .with("error", () => "negative") 37 | .with("info", () => "shakespear") 38 | .with("neutral", () => "gray") 39 | .with("success", () => "positive") 40 | .with("warning", () => "warning") 41 | .exhaustive()} 42 | > 43 | {visible ? t("common.showLess") : t("common.showMore")} 44 | 45 | } 46 | > 47 | {visible ? more : null} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /clients/payment/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ClientContext } from "@swan-io/graphql-client"; 2 | import { ErrorBoundary } from "@swan-io/lake/src/components/ErrorBoundary"; 3 | import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; 4 | import { colors } from "@swan-io/lake/src/constants/design"; 5 | import { ToastStack } from "@swan-io/shared-business/src/components/ToastStack"; 6 | import { Suspense } from "react"; 7 | import { match } from "ts-pattern"; 8 | import { ErrorView } from "./components/ErrorView"; 9 | import { PaymentArea } from "./components/PaymentArea"; 10 | import { Preview } from "./components/Preview"; 11 | import { NotFoundPage } from "./pages/NotFoundPage"; 12 | import { client } from "./utils/gql"; 13 | import { logFrontendError } from "./utils/logger"; 14 | import { Router } from "./utils/routes"; 15 | 16 | export const App = () => { 17 | const route = Router.useRoute(["PaymentArea", "Preview"]); 18 | 19 | return ( 20 | logFrontendError(error)} fallback={() => }> 21 | }> 22 | 23 | {match(route) 24 | .with({ name: "PaymentArea" }, ({ params: { paymentLinkId } }) => ( 25 | 26 | )) 27 | .with({ name: "Preview" }, ({ params }) => ) 28 | .otherwise(() => ( 29 | 30 | ))} 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /scripts/graphql/downloadSchemas.ts: -------------------------------------------------------------------------------- 1 | import { IntrospectionQuery, buildClientSchema, getIntrospectionQuery, printSchema } from "graphql"; 2 | import { execSync } from "node:child_process"; 3 | import fs from "node:fs"; 4 | import path from "pathe"; 5 | 6 | const query = getIntrospectionQuery(); 7 | 8 | const getIntrospection = (name: string, url: string) => 9 | fetch(url, { 10 | method: "POST", 11 | headers: { "Content-Type": "application/json" }, 12 | body: JSON.stringify({ query }), 13 | }) 14 | .then(res => res.json()) 15 | .then(res => res as { data: IntrospectionQuery }) 16 | .then(res => res.data) 17 | .then(res => buildClientSchema(res)) 18 | .then(res => printSchema(res)) 19 | .then(schema => 20 | fs.writeFileSync(path.join(__dirname, `dist/${name}-schema.gql`), schema, "utf-8"), 21 | ) 22 | .catch(err => { 23 | console.error(err); 24 | process.exit(1); 25 | }); 26 | 27 | void Promise.all([ 28 | getIntrospection("partner-admin", "https://api.swan.io/sandbox-partner-admin/graphql"), 29 | getIntrospection("partner", "https://api.swan.io/live-partner/graphql"), 30 | getIntrospection("unauthenticated", "https://api.swan.io/live-unauthenticated/graphql"), 31 | ]).then(() => { 32 | execSync( 33 | `generate-schema-config scripts/graphql/dist/partner-admin-schema.gql scripts/graphql/dist/partner-admin-schema-config.json`, 34 | ); 35 | execSync( 36 | `generate-schema-config scripts/graphql/dist/partner-schema.gql scripts/graphql/dist/partner-schema-config.json`, 37 | ); 38 | execSync( 39 | `generate-schema-config scripts/graphql/dist/unauthenticated-schema.gql scripts/graphql/dist/unauthenticated-schema-config.json`, 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /scripts/release/helpers.ts: -------------------------------------------------------------------------------- 1 | import childProcess from "node:child_process"; 2 | import pc from "picocolors"; 3 | 4 | export const logError = (...error: string[]) => 5 | console.error(`${pc.red("ERROR")} ${error.join("\n")}` + "\n"); 6 | 7 | export const exec = (cmd: string): Promise => 8 | new Promise((resolve, reject) => { 9 | childProcess.exec(cmd, (error, stdout, stderr) => { 10 | if (error) { 11 | return reject(error); 12 | } 13 | 14 | const out = stdout === '""' ? "" : stdout.trim(); 15 | const err = stderr === '""' ? "" : stderr.trim(); 16 | 17 | resolve(out || err); 18 | }); 19 | }); 20 | 21 | export const isExecOk = (cmd: string) => 22 | exec(cmd) 23 | .then(() => true) 24 | .catch(() => false); 25 | 26 | export const isExecKo = (cmd: string) => 27 | exec(cmd) 28 | .then(() => false) 29 | .catch(() => true); 30 | 31 | export const quote = (value: string) => { 32 | if (value === "") { 33 | return "''"; 34 | } 35 | if (!/[^A-Za-z0-9_/:=-]/.test(value)) { 36 | return value; 37 | } 38 | 39 | // from https://github.com/xxorax/node-shell-escape/blob/master/shell-escape.js 40 | return `'${value.replace(/'/g, "'\\''")}'` 41 | .replace(/^(?:'')+/g, "") // unduplicate single-quote at the beginning 42 | .replace(/\\'''/g, "\\'"); // remove non-escaped single-quote if there are enclosed between 2 escaped 43 | }; 44 | 45 | export const updateGhPagerConfig = () => exec('gh config set pager "less -F -X"'); 46 | 47 | export const getLatestGhRelease = () => 48 | exec("gh release list --json tagName --limit 1") 49 | .then(output => JSON.parse(output) as { tagName: string }[]) 50 | .then(output => output[0]?.tagName); 51 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "packageManager": "pnpm@10.26.1", 6 | "engines": { 7 | "node": "^24.12.0" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:swan-io/swan-partner-frontend.git" 12 | }, 13 | "scripts": { 14 | "docusaurus": "docusaurus", 15 | "start": "docusaurus start", 16 | "build": "docusaurus build", 17 | "swizzle": "docusaurus swizzle", 18 | "deploy": "docusaurus deploy", 19 | "clear": "docusaurus clear", 20 | "serve": "docusaurus serve", 21 | "write-translations": "docusaurus write-translations", 22 | "write-heading-ids": "docusaurus write-heading-ids" 23 | }, 24 | "pnpm": { 25 | "onlyBuiltDependencies": [], 26 | "peerDependencyRules": { 27 | "ignoreMissing": [ 28 | "react-native" 29 | ] 30 | } 31 | }, 32 | "dependencies": { 33 | "@docusaurus/core": "3.4.0", 34 | "@docusaurus/preset-classic": "3.4.0", 35 | "@docusaurus/theme-search-algolia": "3.4.0", 36 | "@mdx-js/react": "^3.0.1", 37 | "clsx": "^2.1.1", 38 | "prism-react-renderer": "^2.3.1", 39 | "react": "^18.3.1", 40 | "react-dom": "^18.3.1", 41 | "search-insights": "^2.17.3" 42 | }, 43 | "devDependencies": { 44 | "@docusaurus/module-type-aliases": "3.4.0", 45 | "@docusaurus/types": "3.4.0", 46 | "@types/react": "18.3.12", 47 | "typescript": "5.8.3" 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.5%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/docs/specs/banking/members.md: -------------------------------------------------------------------------------- 1 | # Members 2 | 3 | Account memberships provide account access to as many people as needed with various levels of permission. 4 | Learn more about [account memberships](https://docs.swan.io/guide/give-access-to-your-account) in our main documentation. 5 | 6 | ## Primary members content 7 | 8 | Along with the main navigation, the **members page** should include the following content: 9 | 10 | - Button to add a new account membership 11 | - Filters for **status** and **permission type** 12 | - List of current account memberships 13 | - Full name 14 | - Permissions (_can view account_, _can initiate payments_, _can manage memberships_, and _can manage beneficiaries_) 15 | - Email 16 | - Phone number 17 | - Current status 18 | 19 | ![Screenshot of the main members page with sample content](./images/members-main.png) 20 | 21 | ## Add a new account member 22 | 23 | When adding a new account member, the user should provide the following information about that person: 24 | 25 | - First or given name 26 | - Last or family name 27 | - Phone number 28 | - Birth date 29 | - Email 30 | - Permissions checkboxes (_can view account_, _can initiate payments_, _can manage memberships_, and _can manage beneficiaries_) 31 | 32 | When the user clicks **Send invitation**, an invitation is sent to the potential new account member by email, which they must open and accept. 33 | 34 | ![Screenshot of modal to invite a new account member](./images/members-new.png) 35 | 36 | :::caution 🇩🇪 Germany 37 | For German accounts, **add a second step** asking for the **residency address** of the potential new account member. If that address in Germany, you must also collect their **tax identification number** (_Steuer-Identifikationsnummer_). 38 | ::: 39 | -------------------------------------------------------------------------------- /clients/banking/src/components/FiltersMobileContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollView } from "@swan-io/lake/src/components/ScrollView"; 2 | import { 3 | backgroundColor, 4 | invariantColors, 5 | negativeSpacings, 6 | spacings, 7 | } from "@swan-io/lake/src/constants/design"; 8 | import { ReactNode } from "react"; 9 | import { StyleSheet, View } from "react-native"; 10 | 11 | const styles = StyleSheet.create({ 12 | base: { 13 | marginHorizontal: negativeSpacings[8], 14 | flexGrow: 1, 15 | flexShrink: 1, 16 | }, 17 | content: { 18 | paddingHorizontal: spacings[8], 19 | }, 20 | gradient: { 21 | bottom: 0, 22 | pointerEvents: "none", 23 | position: "absolute", 24 | top: 0, 25 | width: spacings[8], 26 | }, 27 | gradientLeft: { 28 | left: 0, 29 | backgroundImage: `linear-gradient(to right, ${backgroundColor.default}, ${invariantColors.transparent})`, 30 | }, 31 | gradientRight: { 32 | right: 0, 33 | backgroundImage: `linear-gradient(to left, ${backgroundColor.default}, ${invariantColors.transparent})`, 34 | }, 35 | }); 36 | 37 | type Props = { 38 | children: ReactNode; 39 | large: boolean; 40 | }; 41 | 42 | export const FiltersContainer = ({ children, large }: Props) => { 43 | if (large) { 44 | return children; 45 | } 46 | 47 | return ( 48 | 49 | 54 | {children} 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /clients/banking/src/utils/templateTranslations.ts: -------------------------------------------------------------------------------- 1 | import { P, match } from "ts-pattern"; 2 | import { FeesTypeEnum, RejectedReasonCode } from "../graphql/partner"; 3 | import { isTranslationKey, t } from "./i18n"; 4 | 5 | export const getWiseIctLabel = (key: string) => 6 | match(`transactionDetail.internationalCreditTransfer.${key}`) 7 | .with(P.when(isTranslationKey), key => t(key)) 8 | .otherwise(() => key); 9 | 10 | export const getTransactionRejectedReasonLabel = (reason: RejectedReasonCode) => { 11 | try { 12 | return match(`transactionRejectedReason.${reason}`) 13 | .with(P.when(isTranslationKey), key => t(key)) 14 | .exhaustive(); 15 | } catch { 16 | return; 17 | } 18 | }; 19 | 20 | export const getInstantTransferFallbackReasonLabel = (reason: RejectedReasonCode) => { 21 | return match(`instantTransferFallbackReason.${reason}`) 22 | .with(P.when(isTranslationKey), key => t(key)) 23 | .otherwise(() => t("transaction.instantTransferUnavailable.description")); 24 | }; 25 | 26 | export const getFeesDescription = (fees: Exclude) => { 27 | try { 28 | return match(`transaction.fees.description.${fees}`) 29 | .with(P.when(isTranslationKey), key => t(key)) 30 | .exhaustive(); 31 | } catch { 32 | return; 33 | } 34 | }; 35 | 36 | export const getInternationalTransferFormRouteLabel = (route: string) => { 37 | const key = `transfer.new.internationalTransfer.route.${route}`; 38 | 39 | return match(key) 40 | .with(P.when(isTranslationKey), key => t(key)) 41 | .otherwise(() => route); 42 | }; 43 | 44 | export const formatPascalCaseToWords = (text: string): string => 45 | text.replace(/([A-Z])/g, (_, letter, offset) => 46 | offset === 0 ? letter : ` ${letter.toLowerCase()}`, 47 | ); 48 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "clientKind": "git", 5 | "enabled": false, 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "include": ["clients/*/src/**/*.ts", "clients/*/src/**/*.tsx", "server/src/**/*.ts"], 10 | "ignoreUnknown": true, 11 | "ignore": ["**/*.d.ts", "**/graphql/*.ts"] 12 | }, 13 | "formatter": { 14 | "enabled": false 15 | }, 16 | "organizeImports": { 17 | "enabled": false 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "a11y": { 24 | "noSvgWithoutTitle": "off", 25 | "useAltText": "off", 26 | "useIframeTitle": "off", 27 | "useSemanticElements": "off", 28 | "useValidAriaRole": "off" 29 | }, 30 | "complexity": { 31 | "noForEach": "off", 32 | "useLiteralKeys": "off" 33 | }, 34 | "correctness": { 35 | "noFlatMapIdentity": "off", 36 | "noUnusedVariables": "error", 37 | "noVoidTypeReturn": "off", 38 | "useArrayLiterals": "error", 39 | "useHookAtTopLevel": "warn", 40 | "useJsxKeyInIterable": "off" 41 | }, 42 | "nursery": { 43 | "noCommonJs": "error" 44 | }, 45 | "style": { 46 | "noImplicitBoolean": "error", 47 | "noNamespace": "error", 48 | "noUselessElse": "off", 49 | "useBlockStatements": "warn", 50 | "useImportType": "off", 51 | "useTemplate": "off" 52 | }, 53 | "suspicious": { 54 | "noArrayIndexKey": "off", 55 | "noMisleadingCharacterClass": "off", 56 | "noConfusingVoidType": "off", 57 | "noShadowRestrictedNames": "off" 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /clients/banking/src/assets/images/google-pay.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/utils/twilio.ts: -------------------------------------------------------------------------------- 1 | import { P, isMatching } from "ts-pattern"; 2 | import { env } from "./env"; 3 | import { assertIsDefined, fetchOk, retry, seconds } from "./functions"; 4 | 5 | type NonEmptyArray = [string, ...string[]]; 6 | 7 | const bodyContainsMessages = isMatching({ 8 | messages: P.array({ body: P.string }), 9 | }); 10 | 11 | export const getLastMessages = (startDate: Date): Promise => { 12 | const request = async () => { 13 | const url = new URL( 14 | `https://api.twilio.com/2010-04-01/Accounts/${env.TWILIO_ACCOUNT_ID}/Messages.json`, 15 | ); 16 | 17 | url.searchParams.set("To", env.PHONE_NUMBER); 18 | url.searchParams.set("DateSent>", startDate.toISOString()); 19 | 20 | const response = await fetchOk(url.toString(), { 21 | headers: { 22 | Authorization: `Basic ${Buffer.from( 23 | `${env.TWILIO_ACCOUNT_ID}:${env.TWILIO_AUTH_TOKEN}`, 24 | ).toString("base64")}`, 25 | }, 26 | }); 27 | 28 | const body: unknown = await response.json(); 29 | 30 | if (!bodyContainsMessages(body) || body.messages[0] == null) { 31 | throw new Error(`No message in twilio ${env.PHONE_NUMBER} queue`); 32 | } 33 | 34 | const [head, ...tail] = body.messages; 35 | const messages: NonEmptyArray = [head.body, ...tail.map(({ body }) => body)]; 36 | return messages; 37 | }; 38 | 39 | return retry(request, { 40 | attempts: 6, 41 | delay: seconds(5), 42 | }); 43 | }; 44 | 45 | export const getLastMessageURL = async (startDate: Date): Promise => { 46 | const message = (await getLastMessages(startDate))[0]; 47 | 48 | const url = message 49 | .replace(/\n/g, " ") 50 | .split(" ") 51 | .find(word => word.match(/https?:\/\/.+/)); 52 | 53 | assertIsDefined(url); 54 | return url; 55 | }; 56 | -------------------------------------------------------------------------------- /clients/banking/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import posthog, { SeverityLevel } from "posthog-js"; 2 | import { env } from "./env"; 3 | 4 | type User = { 5 | id: string; 6 | firstName: string | undefined; 7 | lastName: string | undefined; 8 | phoneNumber: string | undefined; 9 | }; 10 | 11 | export const setPostHogUser = ({ id, ...properties }: User) => { 12 | posthog.identify(id, properties); 13 | }; 14 | 15 | const replaceIdInPath = (path: string) => { 16 | return path 17 | .split("/") 18 | .map(segment => { 19 | const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( 20 | segment, 21 | ); 22 | return isUuid ? "" : segment; 23 | }) 24 | .join("/"); 25 | }; 26 | 27 | export const initPostHog = () => { 28 | if (import.meta.env.PROD && env.IS_SWAN_MODE) { 29 | const token = 30 | import.meta.env.DEV || 31 | env.BANKING_URL.includes("master") || 32 | env.BANKING_URL.includes("preprod") 33 | ? "phc_6u4uUv6Mp2a9mw7gOMEH8CiS4UEDlUHGuWlkz2OAYQe" 34 | : "phc_y7DlMezh1CgfrVIvkO2fkZMbJcbMziXCZvrPPWR2X8"; 35 | 36 | posthog.init(token, { 37 | api_host: "https://eu.i.posthog.com", 38 | defaults: "2025-05-24", 39 | before_send: event => { 40 | if (event?.properties.$pathname != null) { 41 | event.properties.$pathname = replaceIdInPath(event.properties.$pathname); 42 | } 43 | 44 | return event; 45 | }, 46 | }); 47 | } 48 | }; 49 | 50 | type Context = Partial<{ 51 | level: SeverityLevel; 52 | tags: Record; 53 | extra: Record; 54 | }>; 55 | 56 | export const logFrontendError = (exception: Error, { extra, level, tags }: Context = {}) => { 57 | posthog.captureException(exception, { extra, level, tags }); 58 | }; 59 | -------------------------------------------------------------------------------- /clients/banking/src/components/CardWizardChoosePinModal.tsx: -------------------------------------------------------------------------------- 1 | import { Future } from "@swan-io/boxed"; 2 | import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; 3 | import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; 4 | import { useRef, useState } from "react"; 5 | import { t } from "../utils/i18n"; 6 | import { CardItemPhysicalChoosePinForm, EditorState } from "./CardItemPhysicalChoosePinForm"; 7 | import { CardItemPhysicalDeliveryAddressFormRef } from "./CardItemPhysicalDeliveryAddressForm"; 8 | 9 | type Props = { 10 | visible: boolean; 11 | onPressClose: () => void; 12 | onSubmit: (editorState: EditorState) => Future; 13 | }; 14 | 15 | export const CardWizardChoosePinModal = ({ visible, onPressClose, onSubmit }: Props) => { 16 | const [isLoading, setIsLoading] = useState(false); 17 | 18 | const choosePinRef = useRef(null); 19 | 20 | return ( 21 | 22 | { 25 | setIsLoading(true); 26 | void onSubmit(editorState).tap(() => setIsLoading(false)); 27 | }} 28 | /> 29 | 30 | 31 | 32 | {t("common.back")} 33 | 34 | 35 | choosePinRef.current?.submit()} 37 | color="partner" 38 | grow={true} 39 | loading={isLoading} 40 | > 41 | {t("common.confirm")} 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /clients/payment/src/pages/CardErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 2 | import { Box } from "@swan-io/lake/src/components/Box"; 3 | import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; 4 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 5 | import { Space } from "@swan-io/lake/src/components/Space"; 6 | import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; 7 | import { colors, spacings } from "@swan-io/lake/src/constants/design"; 8 | import { StyleSheet } from "react-native"; 9 | import { t } from "../utils/i18n"; 10 | import { Router } from "../utils/routes"; 11 | 12 | const styles = StyleSheet.create({ 13 | fill: { 14 | ...commonStyles.fill, 15 | }, 16 | title: { 17 | paddingBottom: spacings[4], 18 | }, 19 | subtitle: { 20 | lineHeight: 24, 21 | }, 22 | }); 23 | 24 | type Props = { 25 | paymentLinkId: string; 26 | }; 27 | export const CardErrorPage = ({ paymentLinkId }: Props) => { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | {t("paymentLink.error.title")} 35 | 36 | 37 | 38 | {t("paymentLink.error.subtitle")} 39 | 40 | 41 | 42 | 43 | Router.replace("PaymentForm", { paymentLinkId })} 47 | > 48 | {t("paymentLink.error.retry")} 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /clients/banking/src/utils/identification.ts: -------------------------------------------------------------------------------- 1 | import { P, isMatching, match } from "ts-pattern"; 2 | import { IdentificationFragment, IdentificationLevelFragment } from "../graphql/partner"; 3 | 4 | const temp__handleLegacyStatus = ( 5 | identificationLevel: IdentificationLevelFragment, 6 | ): Exclude< 7 | IdentificationLevelFragment, 8 | { __typename: "NotStartedIdentificationLevelStatusInfo" } 9 | > => { 10 | if (identificationLevel.__typename === "NotStartedIdentificationLevelStatusInfo") { 11 | return { __typename: "StartedIdentificationLevelStatusInfo", status: "Started" }; 12 | } else { 13 | return identificationLevel; 14 | } 15 | }; 16 | 17 | export const getIdentificationLevelStatusInfo = (identification: IdentificationFragment) => 18 | match(identification) 19 | .returnType< 20 | Exclude< 21 | IdentificationLevelFragment, 22 | { __typename: "NotStartedIdentificationLevelStatusInfo" } 23 | > 24 | >() 25 | .with({ process: "Expert", levels: { expert: P.select() } }, statusInfo => 26 | temp__handleLegacyStatus(statusInfo), 27 | ) 28 | .with( 29 | { 30 | process: "QES", 31 | }, 32 | ({ levels: { expert, qes } }) => 33 | "status" in expert && expert.status === "Valid" 34 | ? temp__handleLegacyStatus(qes) 35 | : temp__handleLegacyStatus(expert), 36 | ) 37 | .with({ process: "PVID", levels: { pvid: P.select() } }, statusInfo => 38 | temp__handleLegacyStatus(statusInfo), 39 | ) 40 | .otherwise(() => ({ 41 | __typename: "StartedIdentificationLevelStatusInfo", 42 | status: "Started", 43 | })); 44 | 45 | export const isReadyToSign = isMatching({ 46 | process: "QES", 47 | levels: { 48 | expert: { status: P.union("Pending", "Valid") }, 49 | qes: { status: P.not(P.union("Started", "Pending")) }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /clients/banking/src/components/ErrorView.tsx: -------------------------------------------------------------------------------- 1 | import { Array, Option } from "@swan-io/boxed"; 2 | import { ClientError } from "@swan-io/graphql-client"; 3 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 4 | import { Box } from "@swan-io/lake/src/components/Box"; 5 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 6 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 7 | import { Space } from "@swan-io/lake/src/components/Space"; 8 | import { translateError } from "@swan-io/shared-business/src/utils/i18n"; 9 | import { useState } from "react"; 10 | import { StyleProp, StyleSheet, ViewStyle } from "react-native"; 11 | import { errorToRequestId } from "../utils/gql"; 12 | 13 | const styles = StyleSheet.create({ 14 | base: { 15 | flexGrow: 1, 16 | flexShrink: 1, 17 | }, 18 | }); 19 | 20 | type Props = { 21 | error?: ClientError; 22 | style?: StyleProp; 23 | }; 24 | 25 | export const ErrorView = ({ error, style }: Props) => { 26 | const [requestId] = useState>(() => { 27 | if (error == null) { 28 | return Option.None(); 29 | } 30 | return Array.findMap(ClientError.toArray(error), error => 31 | Option.fromNullable(errorToRequestId.get(error)), 32 | ); 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | {translateError(error)} 42 | 43 | 44 | {requestId.isSome() ? ( 45 | <> 46 | 47 | ID: {requestId.get()} 48 | 49 | ) : null} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /clients/payment/src/components/ErrorView.tsx: -------------------------------------------------------------------------------- 1 | import { Array, Option } from "@swan-io/boxed"; 2 | import { ClientError } from "@swan-io/graphql-client"; 3 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 4 | import { Box } from "@swan-io/lake/src/components/Box"; 5 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 6 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 7 | import { Space } from "@swan-io/lake/src/components/Space"; 8 | import { translateError } from "@swan-io/shared-business/src/utils/i18n"; 9 | import { useState } from "react"; 10 | import { StyleProp, StyleSheet, ViewStyle } from "react-native"; 11 | import { errorToRequestId } from "../utils/gql"; 12 | 13 | const styles = StyleSheet.create({ 14 | base: { 15 | flexGrow: 1, 16 | flexShrink: 1, 17 | }, 18 | }); 19 | 20 | type Props = { 21 | error?: ClientError; 22 | style?: StyleProp; 23 | }; 24 | 25 | export const ErrorView = ({ error, style }: Props) => { 26 | const [requestId] = useState>(() => { 27 | if (error == null) { 28 | return Option.None(); 29 | } 30 | return Array.findMap(ClientError.toArray(error), error => 31 | Option.fromNullable(errorToRequestId.get(error)), 32 | ); 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | {translateError(error)} 42 | 43 | 44 | {requestId.isSome() ? ( 45 | <> 46 | 47 | ID: {requestId.get()} 48 | 49 | ) : null} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /clients/payment/src/pages/ExpiredPage.tsx: -------------------------------------------------------------------------------- 1 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 2 | import { Box } from "@swan-io/lake/src/components/Box"; 3 | import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; 4 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 5 | import { Space } from "@swan-io/lake/src/components/Space"; 6 | import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; 7 | import { colors, spacings } from "@swan-io/lake/src/constants/design"; 8 | import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; 9 | import { StyleSheet } from "react-native"; 10 | import { GetMerchantPaymentLinkQuery } from "../graphql/unauthenticated"; 11 | import { t } from "../utils/i18n"; 12 | 13 | const styles = StyleSheet.create({ 14 | fill: { 15 | ...commonStyles.fill, 16 | }, 17 | title: { 18 | paddingBottom: spacings[4], 19 | }, 20 | }); 21 | 22 | type Props = { 23 | paymentLink: NonNullable; 24 | }; 25 | 26 | export const ExpiredPage = ({ paymentLink }: Props) => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | {t("paymentLink.linkExpired")} 34 | 35 | 36 | 37 | 38 | {isNotNullish(paymentLink.redirectUrl) && ( 39 | 44 | {t("paymentLink.button.returnToWebsite")} 45 | 46 | )} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /clients/onboarding/src/components/ErrorView.tsx: -------------------------------------------------------------------------------- 1 | import { Array, Option } from "@swan-io/boxed"; 2 | import { ClientError } from "@swan-io/graphql-client"; 3 | import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; 4 | import { Box } from "@swan-io/lake/src/components/Box"; 5 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 6 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 7 | import { Space } from "@swan-io/lake/src/components/Space"; 8 | import { translateError } from "@swan-io/shared-business/src/utils/i18n"; 9 | import { useState } from "react"; 10 | import { StyleProp, StyleSheet, ViewStyle } from "react-native"; 11 | import { errorToRequestId } from "../utils/gql"; 12 | 13 | const styles = StyleSheet.create({ 14 | base: { 15 | flexGrow: 1, 16 | flexShrink: 1, 17 | }, 18 | }); 19 | 20 | type Props = { 21 | error?: ClientError; 22 | style?: StyleProp; 23 | }; 24 | 25 | export const ErrorView = ({ error, style }: Props) => { 26 | const [requestId] = useState>(() => { 27 | if (error == null) { 28 | return Option.None(); 29 | } 30 | return Array.findMap(ClientError.toArray(error), error => 31 | Option.fromNullable(errorToRequestId.get(error)), 32 | ); 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | {translateError(error)} 42 | 43 | 44 | {requestId.isSome() ? ( 45 | <> 46 | 47 | ID: {requestId.get()} 48 | 49 | ) : null} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /.github/workflows/download-translations.yml: -------------------------------------------------------------------------------- 1 | name: Download translations 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" # every day at midnight 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | download-translations: 14 | name: Download translations 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: pnpm/action-setup@v4 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | cache: pnpm 26 | 27 | - name: Install dependencies 28 | run: pnpm install --frozen-lockfile 29 | 30 | - name: Download onboarding translation keys 31 | uses: localazy/download@v1 32 | with: 33 | read_key: ${{ secrets.LOCALAZY_ONBOARDING_READ_KEY }} 34 | write_key: ${{ secrets.LOCALAZY_ONBOARDING_WRITE_KEY }} 35 | groups: onboarding 36 | 37 | - name: Download banking translation keys 38 | uses: localazy/download@v1 39 | with: 40 | read_key: ${{ secrets.LOCALAZY_BANKING_READ_KEY }} 41 | write_key: ${{ secrets.LOCALAZY_BANKING_WRITE_KEY }} 42 | groups: banking 43 | 44 | - name: Download payment translation keys 45 | uses: localazy/download@v1 46 | with: 47 | read_key: ${{ secrets.LOCALAZY_PAYMENT_READ_KEY }} 48 | write_key: ${{ secrets.LOCALAZY_PAYMENT_WRITE_KEY }} 49 | groups: payment 50 | 51 | - name: Sort locales 52 | run: pnpm format-locales 53 | 54 | - uses: peter-evans/create-pull-request@v7 55 | with: 56 | title: Update translations 57 | branch: update-translations 58 | commit-message: update translations from localazy 59 | body: Update translations 60 | sign-commits: true 61 | -------------------------------------------------------------------------------- /server/src/api/oauth2.swan.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notice 3 | * --- 4 | * This file is for Swan's internal usage only 5 | */ 6 | import { Result } from "@swan-io/boxed"; 7 | import { P, match } from "ts-pattern"; 8 | import { OAuth2Error, query } from "./oauth2"; 9 | 10 | const additionalEnv = { 11 | SWAN_AUTH_URL: process.env.SWAN_AUTH_URL as string, 12 | SWAN_AUTH_TOKEN: process.env.SWAN_AUTH_TOKEN, 13 | }; 14 | 15 | class OAuth2ExchangeTokenError extends OAuth2Error { 16 | tag = "OAuth2ExchangeTokenError"; 17 | } 18 | 19 | type ExchangeTokenConfig = 20 | | { type: "ProjectToken"; projectId: string } 21 | | { type: "AccountMemberToken"; projectId: string }; 22 | 23 | export const exchangeToken = (originalAccessToken: string, config: ExchangeTokenConfig) => { 24 | return query(additionalEnv.SWAN_AUTH_URL, { 25 | method: "POST", 26 | body: JSON.stringify( 27 | match(config) 28 | .with({ type: "ProjectToken" }, ({ projectId }) => ({ 29 | token: originalAccessToken, 30 | impersonatedProjectIdByMember: projectId, 31 | clientCredentials: "true", 32 | })) 33 | .with({ type: "AccountMemberToken" }, ({ projectId }) => ({ 34 | token: originalAccessToken, 35 | impersonatedProjectId: projectId, 36 | clientCredentials: "false", 37 | })) 38 | .exhaustive(), 39 | ), 40 | headers: { 41 | "Content-Type": "application/json", 42 | ...(additionalEnv.SWAN_AUTH_TOKEN != null 43 | ? { "x-swan-frontend": additionalEnv.SWAN_AUTH_TOKEN } 44 | : undefined), 45 | }, 46 | }).mapOkToResult(payload => { 47 | return match(payload) 48 | .with({ token: P.string }, ({ token }) => { 49 | return Result.Ok(token); 50 | }) 51 | .otherwise(data => { 52 | return Result.Error(new OAuth2ExchangeTokenError(JSON.stringify(data))); 53 | }); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /scripts/env/lintEnvVariables.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { EOL } from "node:os"; 3 | import path from "pathe"; 4 | import pc from "picocolors"; 5 | 6 | const rootPath = path.resolve(__dirname, "../.."); 7 | 8 | const { parsed: example } = dotenv.config({ 9 | path: path.join(rootPath, ".env.example"), 10 | }); 11 | 12 | if (example == null) { 13 | console.log(`${pc.red("error")} .env.example file is missing.`); 14 | process.exit(1); 15 | } 16 | 17 | const { parsed: env } = dotenv.config({ 18 | path: path.join(rootPath, ".env"), 19 | }); 20 | 21 | if (env == null) { 22 | console.log(`${pc.red("error")} .env file is missing.`); 23 | process.exit(1); 24 | } 25 | 26 | const issues: { kind: "extra" | "mismatch" | "missing"; key: string }[] = []; 27 | const keys = Object.keys(env); 28 | const exampleKeys = Object.keys(example); 29 | 30 | keys.forEach(key => { 31 | const value = env[key]; 32 | const exampleValue = example[key]; 33 | 34 | if (exampleValue == null) { 35 | return issues.push({ kind: "extra", key }); 36 | } 37 | if (exampleValue !== "" && value !== exampleValue) { 38 | return issues.push({ kind: "mismatch", key }); 39 | } 40 | }); 41 | 42 | exampleKeys.forEach(key => { 43 | if (!keys.includes(key)) { 44 | return issues.push({ kind: "missing", key }); 45 | } 46 | }); 47 | 48 | if (issues.length > 0) { 49 | console.log(pc.magenta("Issues in your .env file:")); 50 | 51 | console.log( 52 | issues 53 | .map(issue => { 54 | const start = `[${issue.kind}]`.padEnd(12, " "); 55 | 56 | switch (issue.kind) { 57 | case "extra": 58 | return pc.gray(start + issue.key); 59 | case "mismatch": 60 | return pc.green(start + issue.key); 61 | case "missing": 62 | return pc.red(start + issue.key); 63 | } 64 | }) 65 | .join(EOL), 66 | ); 67 | 68 | process.exit(1); 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/publish-public-registry.yml: -------------------------------------------------------------------------------- 1 | name: Publish to public registry 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | env: 12 | REGISTRY: ghcr.io 13 | GH_TOKEN: ${{ github.token }} 14 | permissions: 15 | contents: write 16 | id-token: write 17 | pull-requests: read 18 | packages: write 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: pnpm/action-setup@v4 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: lts/* 27 | cache: pnpm 28 | 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Check licenses 33 | run: pnpm license-check 34 | 35 | - name: GraphQL Codegen 36 | run: pnpm graphql-codegen 37 | 38 | - name: Typecheck 39 | run: pnpm typecheck 40 | 41 | - name: Lint 42 | run: pnpm lint 43 | 44 | - name: Run tests 45 | run: pnpm test 46 | 47 | - name: Compile project 48 | env: 49 | NODE_OPTIONS: "--max_old_space_size=4096" 50 | run: pnpm build 51 | 52 | - name: Log in to the Container registry 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ${{ env.REGISTRY }} 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Dockerize & Push to Registry 60 | if: ${{ github.repository == 'swan-io/swan-partner-frontend' }} 61 | uses: docker/build-push-action@v5 62 | with: 63 | context: . 64 | file: Dockerfile 65 | push: true 66 | tags: | 67 | ${{ env.REGISTRY }}/swan-io/swan-partner-frontend:latest 68 | ${{ env.REGISTRY }}/swan-io/swan-partner-frontend:${{ github.ref_name }} 69 | -------------------------------------------------------------------------------- /tests/utils/webhook.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./env"; 2 | import { fetchOk, log } from "./functions"; 3 | import { getSession } from "./session"; 4 | 5 | const tokenToEmail = (token: string) => `${token}@email.webhook.site`; 6 | const emailToToken = (email: string) => email.split("@")[0]; 7 | 8 | const request = (input: string, init?: RequestInit) => 9 | fetchOk("https://webhook.site" + input, { 10 | ...init, 11 | headers: { 12 | "Api-Key": env.WEBHOOK_SITE_API_KEY, 13 | "Content-Type": "application/json", 14 | ...init?.headers, 15 | }, 16 | }); 17 | 18 | const createToken = (): Promise => 19 | request("/token", { 20 | method: "POST", 21 | }) 22 | .then(response => response.json()) 23 | .then(({ uuid }: { uuid: string }) => uuid); 24 | 25 | const deleteToken = (token: string) => 26 | request(`/token/${token}`, { 27 | method: "DELETE", 28 | }); 29 | 30 | export const createEmailAddress = async (): Promise => { 31 | const token = await createToken(); 32 | const email = tokenToEmail(token); 33 | 34 | log.info(`Successfully created email address ${email}`); 35 | 36 | return email; 37 | }; 38 | 39 | export const resetEmailAddresses = async () => { 40 | const tokens: string[] = await getSession() 41 | .then(({ benady, saison }) => 42 | [benady.email, saison.email] 43 | .map(email => emailToToken(email)) 44 | .filter((token): token is string => token != null), 45 | ) 46 | .catch(() => []); 47 | 48 | if (tokens.length > 0) { 49 | await Promise.all( 50 | tokens.map(token => 51 | deleteToken(token) 52 | .then(() => { 53 | const email = `${token}@email.webhook.site`; 54 | log.info(`Successfully deleted email address ${email}`); 55 | }) 56 | .catch((error: Error) => { 57 | log.error(error.message); 58 | }), 59 | ), 60 | ); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /clients/banking/src/components/DetailLine.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@swan-io/lake/src/components/Box"; 2 | import { Icon, IconName } from "@swan-io/lake/src/components/Icon"; 3 | import { LakeCopyButton } from "@swan-io/lake/src/components/LakeCopyButton"; 4 | import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel"; 5 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 6 | import { Space } from "@swan-io/lake/src/components/Space"; 7 | import { colors } from "@swan-io/lake/src/constants/design"; 8 | import { ReactNode } from "react"; 9 | import { t } from "../utils/i18n"; 10 | 11 | type DetailLineProps = { 12 | icon?: IconName; 13 | label: string; 14 | text: ReactNode; 15 | }; 16 | 17 | export const DetailLine = ({ icon, label, text }: DetailLineProps) => ( 18 | 22 | icon ? ( 23 | 24 | 25 | 26 | 27 | 28 | {text} 29 | 30 | 31 | ) : ( 32 | 33 | {text} 34 | 35 | ) 36 | } 37 | /> 38 | ); 39 | 40 | type DetailCopiableLineProps = { 41 | label: string; 42 | text: string; 43 | }; 44 | 45 | export const DetailCopiableLine = ({ label, text }: DetailCopiableLineProps) => ( 46 | 55 | } 56 | render={() => ( 57 | 58 | {text} 59 | 60 | )} 61 | /> 62 | ); 63 | -------------------------------------------------------------------------------- /clients/onboarding/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import posthog, { SeverityLevel } from "posthog-js"; 2 | import { AccountCountry } from "../graphql/unauthenticated"; 3 | import { env } from "./env"; 4 | 5 | type OnboardingInfo = { 6 | accountCountry: AccountCountry; 7 | projectId: string; 8 | onboardingType: "Company" | "Individual"; 9 | }; 10 | 11 | export const registerOnboardingInfo = ({ 12 | accountCountry, 13 | projectId, 14 | onboardingType, 15 | }: OnboardingInfo) => { 16 | posthog.register({ accountCountry, projectId, onboardingType }); 17 | }; 18 | 19 | const replaceIdInPath = (path: string) => { 20 | return path 21 | .split("/") 22 | .map(segment => { 23 | const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( 24 | segment, 25 | ); 26 | return isUuid ? "" : segment; 27 | }) 28 | .join("/"); 29 | }; 30 | 31 | export const initPostHog = () => { 32 | if (import.meta.env.PROD && env.IS_SWAN_MODE) { 33 | const token = 34 | import.meta.env.DEV || 35 | env.BANKING_URL.includes("master") || 36 | env.BANKING_URL.includes("preprod") 37 | ? "phc_6u4uUv6Mp2a9mw7gOMEH8CiS4UEDlUHGuWlkz2OAYQe" 38 | : "phc_y7DlMezh1CgfrVIvkO2fkZMbJcbMziXCZvrPPWR2X8"; 39 | 40 | posthog.init(token, { 41 | api_host: "https://eu.i.posthog.com", 42 | defaults: "2025-05-24", 43 | before_send: event => { 44 | if (event?.properties.$pathname != null) { 45 | event.properties.$pathname = replaceIdInPath(event.properties.$pathname); 46 | } 47 | 48 | return event; 49 | }, 50 | }); 51 | } 52 | }; 53 | 54 | type Context = Partial<{ 55 | level: SeverityLevel; 56 | tags: Record; 57 | extra: Record; 58 | }>; 59 | 60 | export const logFrontendError = (exception: Error, { extra, level, tags }: Context = {}) => { 61 | posthog.captureException(exception, { extra, level, tags }); 62 | }; 63 | -------------------------------------------------------------------------------- /clients/banking/src/utils/creditLimit.ts: -------------------------------------------------------------------------------- 1 | import { CreditLimitSettingsRequestFragment } from "../graphql/partner"; 2 | 3 | export const getPendingCreditLimitAmount = ( 4 | creditLimitRequests: CreditLimitSettingsRequestFragment[], 5 | ): { value: number; currency: string } => { 6 | const lastPendingRequest = creditLimitRequests 7 | .filter( 8 | request => 9 | request.statusInfo.__typename === "CreditLimitSettingsRequestPendingReviewStatusInfo", 10 | ) 11 | .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) // Sort by updatedAt descending 12 | .at(0); 13 | 14 | if (lastPendingRequest == null) { 15 | return { 16 | value: 0, 17 | currency: "EUR", 18 | }; 19 | } 20 | 21 | return { 22 | value: Number(lastPendingRequest.amount.value), 23 | currency: lastPendingRequest.amount.currency, 24 | }; 25 | }; 26 | 27 | export const getRefusedCreditLimitAmount = ( 28 | creditLimitRequests: CreditLimitSettingsRequestFragment[], 29 | ): { value: number; currency: string } => { 30 | const lastRefusedRequest = creditLimitRequests 31 | .filter( 32 | request => request.statusInfo.__typename === "CreditLimitSettingsRequestRefusedStatusInfo", 33 | ) 34 | .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) // Sort by updatedAt descending 35 | .at(0); 36 | 37 | if (lastRefusedRequest == null) { 38 | return { 39 | value: 0, 40 | currency: "EUR", 41 | }; 42 | } 43 | 44 | return { 45 | value: Number(lastRefusedRequest.amount.value), 46 | currency: lastRefusedRequest.amount.currency, 47 | }; 48 | }; 49 | 50 | export const hasPendingCreditLimitRequest = ( 51 | creditLimitRequests: CreditLimitSettingsRequestFragment[], 52 | ): boolean => { 53 | const lastRequest = creditLimitRequests 54 | .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) 55 | .at(0); 56 | 57 | return lastRequest?.statusInfo.__typename === "CreditLimitSettingsRequestPendingReviewStatusInfo"; 58 | }; 59 | -------------------------------------------------------------------------------- /clients/banking/src/utils/phone.ts: -------------------------------------------------------------------------------- 1 | import { Lazy, Result } from "@swan-io/boxed"; 2 | import { isNullish } from "@swan-io/lake/src/utils/nullish"; 3 | import { Country, countries } from "@swan-io/shared-business/src/constants/countries"; 4 | import { getMostLikelyUserCountry } from "@swan-io/shared-business/src/utils/localization"; 5 | import { E164Number, parsePhoneNumberWithError } from "libphonenumber-js"; 6 | 7 | export const parsePhoneNumber = (value?: string): { country: Country; nationalNumber: string } => { 8 | const fallback = Lazy(() => ({ country: getMostLikelyUserCountry(), nationalNumber: "" })); 9 | 10 | if (isNullish(value) || value.trim() === "") { 11 | return fallback.get(); 12 | } 13 | 14 | try { 15 | const phoneNumber = parsePhoneNumberWithError(value); 16 | const { countryCallingCode, nationalNumber } = phoneNumber; 17 | 18 | if (!phoneNumber.isValid()) { 19 | return fallback.get(); 20 | } 21 | 22 | const country = countries.find( 23 | ({ cca2, idd }) => countryCallingCode === idd && phoneNumber.country === cca2, 24 | ); 25 | 26 | return isNullish(country) 27 | ? fallback.get() 28 | : { country, nationalNumber: String(nationalNumber).replace(/[^0-9]/g, "") }; 29 | } catch { 30 | return fallback.get(); 31 | } 32 | }; 33 | 34 | export const prefixPhoneNumber = (country: Country, nationalNumber: string) => { 35 | const sanitized = nationalNumber.replace(/[^+0-9]/g, ""); 36 | 37 | return Result.fromExecution<{ valid: true; e164: E164Number } | { valid: false }>(() => { 38 | const phoneNumber = parsePhoneNumberWithError(sanitized, { defaultCallingCode: country.idd }); 39 | 40 | return phoneNumber.isValid() ? { valid: true, e164: phoneNumber.number } : { valid: false }; 41 | }).getOr({ valid: false }); 42 | }; 43 | 44 | export const maskPhoneNumber = (value: string) => 45 | value.replace( 46 | /(\d{3})(\d+)(\d{3})/, 47 | (_, $1: string, $2: string, $3: string) => `${$1}${"*".repeat($2.length)}${$3}`, 48 | ); 49 | -------------------------------------------------------------------------------- /tests/utils/selectors.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test"; 2 | import { Merge } from "type-fest"; 3 | 4 | type Parent = Page | Locator; 5 | 6 | type GetByTextOptions = { exact?: boolean }; 7 | type GetByRoleOptions = Merge[1], "name">, GetByTextOptions>; 8 | type ClickOptions = Merge[0], GetByTextOptions>; 9 | type WaitForOptions = Merge[0], GetByTextOptions>; 10 | 11 | // Selectors 12 | 13 | export const getButtonByName = ( 14 | parent: Parent, 15 | name: string | RegExp, 16 | { exact = typeof name === "string", ...options }: GetByRoleOptions = {}, 17 | ) => parent.getByRole("button", { exact, ...options, name }); 18 | 19 | export const getLinkByName = ( 20 | parent: Parent, 21 | name: string | RegExp, 22 | { exact = typeof name === "string", ...options }: GetByRoleOptions = {}, 23 | ) => parent.getByRole("link", { exact, ...options, name }); 24 | 25 | export const getByText = ( 26 | parent: Parent, 27 | text: string | RegExp, 28 | { exact = typeof text === "string", ...options }: GetByTextOptions = {}, 29 | ) => parent.getByText(text, { exact, ...options }); 30 | 31 | // Actions 32 | 33 | export const clickOnLink = ( 34 | parent: Parent, 35 | name: string | RegExp, 36 | { exact, ...options }: ClickOptions = {}, 37 | ) => getLinkByName(parent, name, { exact }).click(options); 38 | 39 | export const clickOnButton = ( 40 | parent: Parent, 41 | name: string | RegExp, 42 | { exact, ...options }: ClickOptions = {}, 43 | ) => getButtonByName(parent, name, { exact }).click(options); 44 | 45 | export const clickOnText = ( 46 | parent: Parent, 47 | text: string | RegExp, 48 | { exact, ...options }: ClickOptions = {}, 49 | ) => getByText(parent, text, { exact }).click(options); 50 | 51 | export const waitForText = ( 52 | parent: Parent, 53 | text: string | RegExp, 54 | { exact, ...options }: WaitForOptions = {}, 55 | ) => getByText(parent, text, { exact }).waitFor(options); 56 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swan-io/frontend-server", 3 | "description": "Swan frontend server", 4 | "version": "1.26.19", 5 | "private": true, 6 | "packageManager": "pnpm@10.26.1", 7 | "engines": { 8 | "node": "^24.12.0" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:swan-io/swan-partner-frontend.git" 13 | }, 14 | "scripts": { 15 | "build": "pnpm clean && tsc -p tsconfig.json && shx cp -r ./src/views ./dist", 16 | "clean": "shx rm -rf dist", 17 | "prepack": "tsc --build", 18 | "start": "node -r ./dist/tracing.js ./dist/index.js", 19 | "start-swan": "node -r ./dist/tracing.js ./dist/index.swan.js" 20 | }, 21 | "dependencies": { 22 | "@fastify/accepts": "5.0.2", 23 | "@fastify/cors": "11.1.0", 24 | "@fastify/helmet": "13.0.1", 25 | "@fastify/middie": "9.0.3", 26 | "@fastify/reply-from": "12.3.1", 27 | "@fastify/secure-session": "8.2.0", 28 | "@fastify/sensible": "6.0.3", 29 | "@fastify/static": "8.2.0", 30 | "@fastify/view": "10.0.2", 31 | "@opentelemetry/auto-instrumentations-node": "0.62.1", 32 | "@opentelemetry/core": "2.0.1", 33 | "@opentelemetry/exporter-trace-otlp-proto": "0.203.0", 34 | "@opentelemetry/propagator-jaeger": "2.0.1", 35 | "@opentelemetry/sdk-node": "0.203.0", 36 | "@opentelemetry/sdk-trace-base": "2.0.1", 37 | "@swan-io/boxed": "3.2.1", 38 | "@types/escape-html": "1.0.4", 39 | "escape-html": "1.0.3", 40 | "fastify": "5.5.0", 41 | "graphql-request": "6.1.0", 42 | "graphql-tag": "2.12.6", 43 | "mustache": "4.2.0", 44 | "node-mailjet": "6.0.9", 45 | "pathe": "2.0.3", 46 | "picocolors": "1.1.1", 47 | "pino-pretty": "13.1.1", 48 | "tggl-client": "3.2.1", 49 | "ts-pattern": "5.8.0", 50 | "valienv": "1.0.0" 51 | }, 52 | "devDependencies": { 53 | "@types/accepts": "1.3.7", 54 | "@types/mustache": "4.2.6", 55 | "graphql": "16.11.0", 56 | "jsonc-parser": "3.3.1", 57 | "shx": "0.4.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /scripts/build/index.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import { execSync } from "child_process"; 3 | import fs from "fs"; 4 | import path from "pathe"; 5 | import pc from "picocolors"; 6 | import { build } from "vite"; 7 | 8 | const { version } = JSON.parse( 9 | fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8"), 10 | ) as { version: string }; 11 | 12 | const appNames = ["onboarding", "banking", "payment"]; 13 | 14 | console.log(``); 15 | console.log(`${pc.magenta("swan-partner-frontend")}`); 16 | console.log(`${pc.white("---")}`); 17 | 18 | void (async () => { 19 | console.log(`${pc.magenta("server")} ${pc.gray("building")}`); 20 | execSync(`cd server && pnpm build`); 21 | console.log(`${pc.magenta("server")} ${pc.green("done")}`); 22 | console.log(``); 23 | 24 | for (const appName of appNames) { 25 | console.log(`${pc.magenta(appName)} ${pc.gray("building")}`); 26 | const cwd = process.cwd(); 27 | 28 | try { 29 | await build({ 30 | root: path.join(cwd, "clients", appName), 31 | plugins: [react()], 32 | logLevel: "error", 33 | mode: "production", 34 | define: { "process.env.NODE_ENV": JSON.stringify("production") }, 35 | resolve: { 36 | alias: { "react-native": "react-native-web" }, 37 | }, 38 | build: { 39 | outDir: path.join(cwd, "server", "dist", "static", appName), 40 | // The polyfill generates a bug on Safari, where it makes the module 41 | // always be invalidated due to credentials being sent (i.e. Cookies) 42 | polyfillModulePreload: false, 43 | sourcemap: true, 44 | target: ["es2019", "edge80", "firefox72", "chrome80", "safari12"], 45 | assetsDir: `assets/${version}`, 46 | }, 47 | }); 48 | } catch (error) { 49 | if (error != null) { 50 | console.error(error); 51 | process.exit(1); 52 | } 53 | } 54 | 55 | console.log(`${pc.magenta(appName)} ${pc.green("done")}`); 56 | console.log(``); 57 | } 58 | })(); 59 | -------------------------------------------------------------------------------- /clients/banking/src/components/CardItemPhysicalRenewalWizard.tsx: -------------------------------------------------------------------------------- 1 | import { Future } from "@swan-io/boxed"; 2 | import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; 3 | import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; 4 | import { useRef, useState } from "react"; 5 | import { CompleteAddressInput } from "../graphql/partner"; 6 | import { t } from "../utils/i18n"; 7 | import { 8 | Address, 9 | CardItemPhysicalDeliveryAddressForm, 10 | CardItemPhysicalDeliveryAddressFormRef, 11 | } from "./CardItemPhysicalDeliveryAddressForm"; 12 | 13 | type Props = { 14 | visible: boolean; 15 | initialAddress: Address | undefined; 16 | onPressClose: () => void; 17 | onSubmit: (address: CompleteAddressInput) => Future; 18 | }; 19 | 20 | export const CardItemPhysicalRenewalWizard = ({ 21 | visible, 22 | initialAddress, 23 | onPressClose, 24 | onSubmit, 25 | }: Props) => { 26 | const [isLoading, setIsLoading] = useState(false); 27 | 28 | const deliveryAddressRef = useRef(null); 29 | 30 | return ( 31 | 36 | { 40 | setIsLoading(true); 41 | return onSubmit(address).tap(() => setIsLoading(false)); 42 | }} 43 | /> 44 | 45 | 46 | 47 | {t("common.back")} 48 | 49 | 50 | deliveryAddressRef.current?.submit()} 52 | color="partner" 53 | grow={true} 54 | loading={isLoading} 55 | > 56 | {t("common.confirm")} 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /clients/banking/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "@swan-io/lake/src/assets/fonts/Inter.css"; 2 | import "@swan-io/lake/src/assets/main.css"; 3 | 4 | import { ResizeObserver } from "@juggle/resize-observer"; 5 | import "core-js/proposals/array-flat-map"; 6 | import "core-js/proposals/change-array-by-copy-stage-4"; 7 | import "core-js/proposals/object-from-entries"; 8 | import "core-js/proposals/promise-all-settled"; 9 | import "core-js/proposals/relative-indexing-method"; 10 | import "core-js/proposals/string-replace-all-stage-4"; 11 | 12 | // overrides shared-business supported languages 13 | import "./utils/i18n"; 14 | 15 | import { Option } from "@swan-io/boxed"; 16 | import { isNullish } from "@swan-io/lake/src/utils/nullish"; 17 | import { AppRegistry } from "react-native"; 18 | import { match } from "ts-pattern"; 19 | import { App } from "./App"; 20 | import { initPostHog } from "./utils/logger"; 21 | import { projectConfiguration } from "./utils/projectId"; 22 | 23 | if (isNullish(window.ResizeObserver)) { 24 | window.ResizeObserver = ResizeObserver; 25 | } 26 | 27 | initPostHog(); 28 | 29 | const rootTag = document.getElementById("app-root"); 30 | 31 | match(projectConfiguration) 32 | .with(Option.P.None, () => { 33 | const url = new URL(window.location.href); 34 | const [...envHostName] = url.hostname.split("."); 35 | url.hostname = ["partner", ...envHostName].join("."); 36 | url.pathname = "/"; 37 | // local dev tweak 38 | if (url.port === "8082") { 39 | url.port = "8080"; 40 | } 41 | window.location.replace(url); 42 | }) 43 | .otherwise(() => { 44 | if (rootTag != null) { 45 | AppRegistry.registerComponent("App", () => App); 46 | AppRegistry.runApplication("App", { rootTag }); 47 | } 48 | }); 49 | 50 | console.log( 51 | `%c👋 Hey, looks like you're curious about how Swan works! 52 | %c👀 Swan is looking for many curious people. 53 | 54 | %c➡️ Feel free to check out https://www.welcometothejungle.com/fr/companies/swan/jobs, or send a message to join-us@swan.io`, 55 | "font-size: 1.125em; font-weight: bold;", 56 | "font-size: 1.125em;", 57 | "font-size: 1.125em;", 58 | ); 59 | -------------------------------------------------------------------------------- /scripts/deploy/deploy.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert"); 2 | const { execSync } = require("node:child_process"); 3 | const fs = require("node:fs"); 4 | const os = require("node:os"); 5 | const path = require("node:path"); 6 | 7 | const tmp = os.tmpdir(); 8 | const repoName = "deploy"; 9 | 10 | assert(process.env.TAG); 11 | assert(process.env.DEPLOY_SWAN_TOKEN); 12 | assert(process.env.DEPLOY_SWAN_REPOSITORY); 13 | assert(process.env.DEPLOY_ENVIRONMENT); 14 | assert(process.env.DEPLOY_APP_NAME); 15 | assert(process.env.DEPLOY_GIT_USER); 16 | assert(process.env.DEPLOY_GIT_EMAIL); 17 | 18 | execSync(`git config --global user.name ${process.env.DEPLOY_GIT_USER}`); 19 | execSync(`git config --global user.email ${process.env.DEPLOY_GIT_EMAIL}`); 20 | 21 | execSync(`rm -fr ${tmp}/${repoName}`); 22 | 23 | execSync( 24 | `cd ${tmp} && git clone --single-branch --branch master https://projects:${process.env.DEPLOY_SWAN_TOKEN}@${process.env.DEPLOY_SWAN_REPOSITORY} ${repoName}`, 25 | ); 26 | 27 | const file = fs.readFileSync( 28 | path.join( 29 | tmp, 30 | repoName, 31 | process.env.DEPLOY_ENVIRONMENT, 32 | `${process.env.DEPLOY_APP_NAME}-values.yaml`, 33 | ), 34 | "utf-8", 35 | ); 36 | 37 | const updatedFile = file.replaceAll(/\btag: .+/g, `tag: ${process.env.TAG}`); 38 | 39 | fs.writeFileSync( 40 | path.join( 41 | tmp, 42 | repoName, 43 | process.env.DEPLOY_ENVIRONMENT, 44 | `${process.env.DEPLOY_APP_NAME}-values.yaml`, 45 | ), 46 | updatedFile, 47 | "utf-8", 48 | ); 49 | 50 | execSync( 51 | `cd ${tmp}/${repoName} && git commit --allow-empty -am "Update with tag: ${process.env.TAG}, image(s): ${process.env.DEPLOY_APP_NAME}"`, 52 | ); 53 | 54 | const push = () => 55 | execSync(`cd ${tmp}/${repoName} && git pull --rebase origin master && git push origin master`); 56 | 57 | let remainingAttempts = 3; 58 | let lastError; 59 | while (remainingAttempts-- > 0) { 60 | try { 61 | push(); 62 | break; 63 | } catch (err) { 64 | lastError = err; 65 | } 66 | } 67 | 68 | if (remainingAttempts === 0 && lastError != null) { 69 | console.error(lastError); 70 | process.exit(1); 71 | } 72 | -------------------------------------------------------------------------------- /scripts/tests/matchTranslations.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "@swan-io/boxed"; 2 | import glob from "fast-glob"; 3 | import { readFileSync, writeFileSync } from "fs"; 4 | import path from "pathe"; 5 | import { match } from "ts-pattern"; 6 | import { mapKeys } from "../../tests/utils/functions"; 7 | 8 | const isStringRecord = (value: unknown): value is Record => 9 | String(value) === "[object Object]" && 10 | value != null && 11 | Object.values(value).every(item => typeof item === "string"); 12 | 13 | const parseJsonSafe = (json: string) => 14 | Result.fromExecution(() => JSON.parse(json) as unknown) 15 | .map(object => 16 | match(object) 17 | .when(isStringRecord, record => record) 18 | .otherwise(() => ({})), 19 | ) 20 | .match({ 21 | Ok: record => record, 22 | Error: () => ({}), 23 | }); 24 | 25 | const readJsonFile = (filePath: string) => { 26 | const content = readFileSync(filePath, "utf-8"); 27 | return parseJsonSafe(content); 28 | }; 29 | 30 | const main = () => { 31 | const clients = ["banking", "onboarding"] as const; 32 | const shared = mapKeys( 33 | "shared", 34 | readJsonFile("node_modules/@swan-io/shared-business/src/locales/en.json"), 35 | ); 36 | 37 | for (const client of clients) { 38 | const translations = mapKeys( 39 | client, 40 | readJsonFile(`${path.resolve(__dirname, `../../clients/${client}/src/locales`)}/en.json`), 41 | ); 42 | const files = glob.sync(`${path.resolve(__dirname, `../../tests`)}/*.${client}.ts`); 43 | 44 | for (const file of files) { 45 | let code = readFileSync(file, "utf-8"); 46 | const combined: Record = { ...translations, ...shared }; 47 | const matches = code.match(/"(\\.|[^"])*"/g)?.map(v => v.replace(/"/g, "")); 48 | 49 | matches?.forEach(match => { 50 | const k = Object.keys(combined).find(key => combined[key] === match); 51 | if (k != null) { 52 | code = code.replaceAll(`"${match}"`, `t("${k}")`); 53 | } 54 | }); 55 | 56 | writeFileSync(file, code, "utf-8"); 57 | } 58 | } 59 | }; 60 | 61 | main(); 62 | -------------------------------------------------------------------------------- /clients/banking/src/components/TypePickerLink.tsx: -------------------------------------------------------------------------------- 1 | import { pushUnsafe } from "@swan-io/chicane"; 2 | import { Fill } from "@swan-io/lake/src/components/Fill"; 3 | import { Icon, IconName } from "@swan-io/lake/src/components/Icon"; 4 | import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; 5 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 6 | import { Pressable } from "@swan-io/lake/src/components/Pressable"; 7 | import { Space } from "@swan-io/lake/src/components/Space"; 8 | import { Tile } from "@swan-io/lake/src/components/Tile"; 9 | import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; 10 | import { animations, colors } from "@swan-io/lake/src/constants/design"; 11 | import { useCallback } from "react"; 12 | import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; 13 | 14 | const styles = StyleSheet.create({ 15 | fill: { 16 | ...commonStyles.fill, 17 | }, 18 | base: { 19 | ...animations.fadeAndSlideInFromTop.enter, 20 | animationFillMode: "backwards", 21 | }, 22 | tile: { 23 | flexDirection: "row", 24 | alignItems: "center", 25 | }, 26 | }); 27 | 28 | type Props = { 29 | icon: IconName; 30 | style?: StyleProp; 31 | title: string; 32 | subtitle: string; 33 | url: string; 34 | }; 35 | 36 | export const TypePickerLink = ({ icon, style, title, subtitle, url }: Props) => ( 37 | pushUnsafe(url), [url])} 40 | style={[styles.base, style]} 41 | > 42 | {({ hovered }) => ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | {title} 50 | 51 | 52 | 53 | {subtitle} 54 | 55 | 56 | 57 | 58 | 59 | )} 60 | 61 | ); 62 | -------------------------------------------------------------------------------- /docs/src/theme/DocItem/Footer/index.js: -------------------------------------------------------------------------------- 1 | import { ThemeClassNames } from "@docusaurus/theme-common"; 2 | import { useDoc } from "@docusaurus/theme-common/internal"; 3 | import EditThisPage from "@theme/EditThisPage"; 4 | import LastUpdated from "@theme/LastUpdated"; 5 | import TagsListInline from "@theme/TagsListInline"; 6 | import clsx from "clsx"; 7 | import styles from "./styles.module.css"; 8 | function TagsRow(props) { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 | ); 16 | } 17 | function EditMetaRow({ editUrl, lastUpdatedAt, lastUpdatedBy, formattedLastUpdatedAt }) { 18 | return ( 19 |
20 |
{editUrl && }
21 | 22 |
23 | {(lastUpdatedAt || lastUpdatedBy) && ( 24 | 29 | )} 30 |
31 |
32 | ); 33 | } 34 | export default function DocItemFooter() { 35 | const { metadata } = useDoc(); 36 | const { editUrl, lastUpdatedAt, formattedLastUpdatedAt, lastUpdatedBy, tags } = metadata; 37 | const canDisplayTagsRow = tags.length > 0; 38 | const canDisplayEditMetaRow = !!(editUrl || lastUpdatedAt || lastUpdatedBy); 39 | const canDisplayFooter = canDisplayTagsRow || canDisplayEditMetaRow; 40 | if (!canDisplayFooter) { 41 | return null; 42 | } 43 | return ( 44 |
45 | {canDisplayTagsRow && } 46 | {canDisplayEditMetaRow && ( 47 | 53 | )} 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /clients/banking/src/components/CardCancelConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@swan-io/graphql-client"; 2 | import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; 3 | import { LakeText } from "@swan-io/lake/src/components/LakeText"; 4 | import { Space } from "@swan-io/lake/src/components/Space"; 5 | import { colors } from "@swan-io/lake/src/constants/design"; 6 | import { filterRejectionsToResult } from "@swan-io/lake/src/utils/gql"; 7 | import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; 8 | import { showToast } from "@swan-io/shared-business/src/state/toasts"; 9 | import { translateError } from "@swan-io/shared-business/src/utils/i18n"; 10 | import { CancelCardDocument } from "../graphql/partner"; 11 | import { t } from "../utils/i18n"; 12 | 13 | type Props = { 14 | visible: boolean; 15 | cardId?: string; 16 | onSuccess: () => void; 17 | onPressClose: () => void; 18 | }; 19 | 20 | export const CardCancelConfirmationModal = ({ 21 | visible, 22 | cardId, 23 | onSuccess, 24 | onPressClose, 25 | }: Props) => { 26 | const [cancelCard, cardCancelation] = useMutation(CancelCardDocument); 27 | 28 | const onPressConfirm = () => { 29 | if (cardId != null) { 30 | cancelCard({ cardId }) 31 | .mapOk(data => data.cancelCard) 32 | .mapOkToResult(filterRejectionsToResult) 33 | .tapOk(() => { 34 | onSuccess(); 35 | }) 36 | .tapError(error => { 37 | showToast({ variant: "error", error, title: translateError(error) }); 38 | }); 39 | } 40 | }; 41 | 42 | return ( 43 | 50 | {t("card.cancel.description")} 51 | 52 | 53 | 60 | {t("card.cancel.cancelCard")} 61 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /scripts/release/createPrerelease.ts: -------------------------------------------------------------------------------- 1 | import semver from "semver"; 2 | import { exec, getLatestGhRelease, logError, quote, updateGhPagerConfig } from "./helpers"; 3 | 4 | const getGhPrereleasePullRequest = () => 5 | exec("gh pr list --state merged --json title") 6 | .then(output => JSON.parse(output) as { title: string }[]) 7 | .then(output => output.map(({ title }) => title).find(title => /^v\d+\.\d+.\d+$/.test(title))); 8 | 9 | const createGhPrerelease = async (version: string) => { 10 | await exec(`gh release create ${version} --title ${version} --generate-notes --prerelease`); 11 | 12 | const notes = ( 13 | await exec(`gh release view ${version} --json body`) 14 | .then(output => JSON.parse(output) as { body: string }) 15 | .then(output => output.body) 16 | ) 17 | .split("\n") 18 | .filter(entry => !/^[*-] v\d+\.\d+.\d+/.test(entry)) // remove release PR 19 | .map(line => line.replace(/\r/, "")) // remove windows line breaks 20 | .map(line => line.replace(/by @[^\s]+ in (.+)/, "($1)")) // set link between parentheses 21 | .join("\n"); 22 | 23 | await exec(`gh release edit ${version} --notes ${quote(notes)} --prerelease`); 24 | }; 25 | 26 | (async () => { 27 | await updateGhPagerConfig(); 28 | 29 | const currentVersionTag = await getLatestGhRelease(); 30 | const nextVersionTag = await getGhPrereleasePullRequest(); 31 | 32 | if (nextVersionTag == null) { 33 | logError("Cannot find next version value"); 34 | process.exit(1); 35 | } 36 | 37 | const currentVersion = semver.parse(currentVersionTag); 38 | const nextVersion = semver.parse(nextVersionTag); 39 | 40 | if (currentVersion == null || nextVersion == null) { 41 | logError("Cannot parse versions"); 42 | process.exit(1); 43 | } 44 | 45 | if (nextVersion.compare(currentVersion) <= 0) { 46 | logError( 47 | [ 48 | `Next version is not superior to current one (current: ${currentVersionTag}, next: ${nextVersionTag})`, 49 | 'Make sure that the release pull request title uses the "vX.X.X" format.', 50 | ].join("\n"), 51 | ); 52 | 53 | process.exit(1); 54 | } 55 | 56 | await createGhPrerelease(nextVersionTag); 57 | })().catch(error => { 58 | console.error(error); 59 | process.exit(1); 60 | }); 61 | -------------------------------------------------------------------------------- /server/src/graphql/partner.gql: -------------------------------------------------------------------------------- 1 | query ProjectId { 2 | projectInfo { 3 | id 4 | } 5 | } 6 | 7 | fragment OnboardingRedirectInfo on Onboarding { 8 | __typename 9 | id 10 | account { 11 | legalRepresentativeMembership { 12 | id 13 | } 14 | } 15 | statusInfo { 16 | __typename 17 | status 18 | } 19 | oAuthRedirectParameters { 20 | state 21 | redirectUrl 22 | } 23 | } 24 | 25 | query GetAccountMembershipInvitationData( 26 | $inviterAccountMembershipId: ID! 27 | $inviteeAccountMembershipId: ID! 28 | ) { 29 | projectInfo { 30 | id 31 | name 32 | accentColor 33 | logoUri 34 | } 35 | inviterAccountMembership: accountMembership(id: $inviterAccountMembershipId) { 36 | email 37 | user { 38 | firstName 39 | preferredLastName 40 | fullName 41 | } 42 | account { 43 | name 44 | number 45 | holder { 46 | info { 47 | ... on AccountHolderCompanyInfo { 48 | name 49 | } 50 | ... on AccountHolderIndividualInfo { 51 | name 52 | } 53 | } 54 | } 55 | } 56 | } 57 | inviteeAccountMembership: accountMembership(id: $inviteeAccountMembershipId) { 58 | id 59 | email 60 | statusInfo { 61 | __typename 62 | ... on AccountMembershipInvitationSentStatusInfo { 63 | restrictedTo { 64 | firstName 65 | phoneNumber 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | mutation FinalizeOnboarding($input: FinalizeOnboardingInput!) { 73 | finalizeOnboarding(input: $input) { 74 | __typename 75 | ... on Rejection { 76 | message 77 | } 78 | ... on FinalizeOnboardingSuccessPayload { 79 | onboarding { 80 | __typename 81 | ...OnboardingRedirectInfo 82 | } 83 | } 84 | } 85 | } 86 | 87 | mutation BindAccountMembership($input: BindAccountMembershipInput!) { 88 | bindAccountMembership(input: $input) { 89 | __typename 90 | ... on BindAccountMembershipSuccessPayload { 91 | accountMembership { 92 | id 93 | } 94 | } 95 | ... on Rejection { 96 | message 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, defineConfig, devices } from "@playwright/test"; 2 | import path from "pathe"; 3 | import { env } from "./tests/utils/env"; 4 | 5 | const seconds = (value: number) => value * 1000; 6 | const minutes = (value: number) => value * seconds(60); 7 | 8 | export const testDir = path.join(__dirname, "tests"); 9 | export const testResultsDir = path.join(testDir, "results"); 10 | 11 | const outputDir = path.join(testResultsDir, "output"); 12 | const reportDir = path.join(testResultsDir, "report"); 13 | 14 | export const storagePath = path.join(testResultsDir, "storage.json"); 15 | export const sessionPath = path.join(testResultsDir, "session.json"); 16 | 17 | const useOptions: PlaywrightTestConfig["use"] = { 18 | ...devices["Desktop Chrome"], 19 | headless: env.CI, 20 | locale: "en-US", 21 | actionTimeout: seconds(15), 22 | navigationTimeout: seconds(15), 23 | trace: "retain-on-failure", 24 | screenshot: "only-on-failure", 25 | // video: "retain-on-failure", 26 | viewport: { 27 | width: 1440, 28 | height: 900, 29 | }, 30 | }; 31 | 32 | export default defineConfig({ 33 | globalSetup: path.join(testDir, "global-setup"), 34 | globalTeardown: path.join(testDir, "global-teardown"), 35 | 36 | forbidOnly: env.CI, 37 | maxFailures: 4, 38 | workers: 1, 39 | fullyParallel: true, 40 | preserveOutput: "always", 41 | timeout: minutes(2), 42 | 43 | testDir, 44 | outputDir, 45 | 46 | reporter: [["line"], ["html", { outputFolder: reportDir }]], 47 | 48 | expect: { 49 | timeout: seconds(20), 50 | }, 51 | 52 | projects: [ 53 | { 54 | name: "setup", 55 | testMatch: /.*\.setup\.ts/, 56 | use: useOptions, 57 | }, 58 | { 59 | name: "onboarding", 60 | dependencies: ["setup"], 61 | testMatch: /.*\.onboarding\.ts/, 62 | use: useOptions, 63 | }, 64 | { 65 | name: "banking", 66 | dependencies: ["setup"], 67 | testMatch: /.*\.banking\.ts/, 68 | use: { 69 | ...useOptions, 70 | storageState: storagePath, 71 | }, 72 | }, 73 | ], 74 | 75 | webServer: { 76 | command: "pnpm dev-e2e", 77 | url: env.BANKING_URL, 78 | reuseExistingServer: !env.CI, 79 | stderr: "pipe", 80 | stdout: "ignore", 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /clients/banking/src/components/VerificationRenewal/VerificationRenewalFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@swan-io/lake/src/components/Box"; 2 | import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; 3 | import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; 4 | import { Space } from "@swan-io/lake/src/components/Space"; 5 | import { spacings } from "@swan-io/lake/src/constants/design"; 6 | import { StyleSheet } from "react-native"; 7 | import { t, TranslationKey } from "../../utils/i18n"; 8 | 9 | const styles = StyleSheet.create({ 10 | root: { 11 | alignSelf: "stretch", 12 | }, 13 | container: { 14 | paddingVertical: 16, 15 | }, 16 | containerDesktop: { 17 | paddingVertical: spacings[32], 18 | }, 19 | }); 20 | 21 | type Props = { 22 | onPrevious?: () => void; 23 | onNext: () => void; 24 | nextLabel?: TranslationKey; 25 | loading?: boolean; 26 | justifyContent?: "start" | "center" | "end"; 27 | }; 28 | //TODO: put this footer in lake for rekyc and onboarding 29 | export const VerificationRenewalFooter = ({ 30 | onPrevious, 31 | onNext, 32 | nextLabel = "verificationRenewal.next", 33 | loading, 34 | justifyContent = "start", 35 | }: Props) => { 36 | return ( 37 | 38 | {({ large, small }) => ( 39 | 44 | {onPrevious ? ( 45 | <> 46 | 53 | {t("verificationRenewal.back")} 54 | 55 | 56 | 57 | 58 | ) : null} 59 | 60 | 67 | {t(nextLabel)} 68 | 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /docs/docs/invitation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Automate account membership invitation 3 | sidebar_label: Automate account membership invitation 4 | --- 5 | 6 | When a new account membership is created, the invited user must accept the membership. 7 | 8 | By default, this is done with an invitation link. 9 | The link needs to be copied manually from the app and sent to the invited user. 10 | Then, the invited user opens the links and follows the prompts to accept the membership. 11 | 12 | Alternatively, you can **automate sending the link** by including a `sendAccountMembershipInvitation` in the `start` function called in `server/src/index.ts`. 13 | 14 | ## Write expected signature 15 | 16 | The expected signature contains the following information: 17 | 18 | ```ts 19 | export type InvitationConfig = { 20 | accessToken: string; 21 | requestLanguage: string; 22 | inviteeAccountMembershipId: string; 23 | inviterAccountMembershipId: string; 24 | }; 25 | 26 | type sendAccountMembershipInvitation = (config: InvitationConfig) => Promise; 27 | ``` 28 | 29 | ## Review example 30 | 31 | Review Swan's internal implementation: 32 | 33 | ```ts title="index.ts" 34 | const sendAccountMembershipInvitation = (invitationConfig: InvitationConfig) => { 35 | // Get data from the API 36 | return ( 37 | getAccountMembershipInvitationData({ 38 | accessToken: invitationConfig.accessToken, 39 | inviteeAccountMembershipId: invitationConfig.inviteeAccountMembershipId, 40 | inviterAccountMembershipId: invitationConfig.inviterAccountMembershipId, 41 | }) 42 | // Build mailjet config 43 | .mapOkToResult(invitationData => 44 | getMailjetInput({ invitationData, requestLanguage: invitationConfig.requestLanguage }), 45 | ) 46 | // Send email 47 | .flatMapOk(data => { 48 | return Future.fromPromise(mailjet.post("send", { version: "v3.1" }).request(data)); 49 | }) 50 | .resultToPromise() 51 | ); 52 | }; 53 | ``` 54 | 55 | ## Pass to function parameters 56 | 57 | Pass the expected signature to the `start` function parameters: 58 | 59 | ```ts title="index.ts" 60 | start({ 61 | mode: env.NODE_ENV, 62 | // ... 63 | sendAccountMembershipInvitation, 64 | }); 65 | ``` 66 | 67 | :::success 68 | With this configuration, sending the invitation is automated thanks to your function. 69 | ::: 70 | --------------------------------------------------------------------------------