diff --git a/clients/banking/package.json b/clients/banking/package.json
index 347a4ccbc..849d2178b 100644
--- a/clients/banking/package.json
+++ b/clients/banking/package.json
@@ -11,18 +11,16 @@
"clean": "tsc --clean"
},
"dependencies": {
- "@0no-co/graphql.web": "1.0.6",
"@formatjs/intl": "2.10.1",
"@juggle/resize-observer": "3.4.0",
"@sentry/react": "7.109.0",
"@swan-io/boxed": "2.1.1",
"@swan-io/chicane": "2.0.0",
+ "@swan-io/graphql-client": "0.1.0-beta4",
"@swan-io/lake": "7.3.3",
"@swan-io/request": "1.0.4",
"@swan-io/shared-business": "7.3.3",
"@swan-io/use-form": "2.0.0",
- "@urql/devtools": "2.0.3",
- "@urql/exchange-graphcache": "7.0.0",
"core-js": "3.36.1",
"dayjs": "1.11.10",
"iban": "0.0.14",
@@ -35,9 +33,7 @@
"react-ux-form": "1.5.0",
"rifm": "0.12.1",
"tggl-client": "1.13.2",
- "ts-pattern": "5.1.0",
- "urql": "4.0.7",
- "wonka": "6.3.4"
+ "ts-pattern": "5.1.0"
},
"devDependencies": {
"@types/iban": "0.0.35",
diff --git a/clients/banking/src/App.tsx b/clients/banking/src/App.tsx
index 826d198c8..e1a668467 100644
--- a/clients/banking/src/App.tsx
+++ b/clients/banking/src/App.tsx
@@ -1,22 +1,23 @@
+import { AsyncData } from "@swan-io/boxed";
+import { ClientContext, useQuery } from "@swan-io/graphql-client";
import { ErrorBoundary } from "@swan-io/lake/src/components/ErrorBoundary";
-import { LoadingView } from "@swan-io/lake/src/components/LoadingView";
import { ToastStack } from "@swan-io/lake/src/components/ToastStack";
import { colors } from "@swan-io/lake/src/constants/design";
import { isNotNullishOrEmpty } from "@swan-io/lake/src/utils/nullish";
-import { Suspense } from "react";
import { StyleSheet } from "react-native";
import { P, match } from "ts-pattern";
-import { Provider as ClientProvider } from "urql";
-import { AccountArea } from "./components/AccountArea";
+import { AccountMembershipArea } from "./components/AccountMembershipArea";
import { ErrorView } from "./components/ErrorView";
import { ProjectRootRedirect } from "./components/ProjectRootRedirect";
+import { Redirect } from "./components/Redirect";
+import { AuthStatusDocument } from "./graphql/partner";
import { NotFoundPage } from "./pages/NotFoundPage";
import { PopupCallbackPage } from "./pages/PopupCallbackPage";
import { ProjectLoginPage } from "./pages/ProjectLoginPage";
+import { partnerClient, unauthenticatedClient } from "./utils/gql";
import { logFrontendError } from "./utils/logger";
import { projectConfiguration } from "./utils/projectId";
import { Router } from "./utils/routes";
-import { isUnauthorizedError, partnerClient } from "./utils/urql";
const styles = StyleSheet.create({
base: {
@@ -25,51 +26,76 @@ const styles = StyleSheet.create({
},
});
+const AppContainer = () => {
+ const route = Router.useRoute(["ProjectLogin", "ProjectRootRedirect", "AccountArea"]);
+ const [authStatus] = useQuery(AuthStatusDocument, {});
+
+ const loginInfo = authStatus
+ .mapOk(data => data.user?.id != null)
+ .map(result => ({ isLoggedIn: result.getWithDefault(false) }));
+
+ return match(loginInfo)
+ .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => null)
+ .with(AsyncData.P.Done(P.select()), ({ isLoggedIn }) => {
+ return match(route)
+ .with({ name: "ProjectLogin" }, ({ params: { sessionExpired } }) =>
+ projectConfiguration.match({
+ None: () => ,
+ Some: ({ projectId }) =>
+ isLoggedIn ? (
+ // Skip login and redirect to the root URL
+
+ ) : (
+
+
+
+ ),
+ }),
+ )
+ .with({ name: "AccountArea" }, { name: "ProjectRootRedirect" }, route =>
+ isLoggedIn ? (
+ match(route)
+ .with({ name: "AccountArea" }, ({ params: { accountMembershipId } }) => (
+
+ ))
+ .with({ name: "ProjectRootRedirect" }, ({ params: { to, source } }) => (
+
+ ))
+ .with(P.nullish, () => )
+ .exhaustive()
+ ) : (
+
+ ),
+ )
+ .with(P.nullish, () => )
+ .exhaustive();
+ })
+ .exhaustive();
+};
+
export const App = () => {
- const route = Router.useRoute([
- "PopupCallback",
- "ProjectLogin",
- "ProjectRootRedirect",
- "AccountArea",
- ]);
+ const route = Router.useRoute(["PopupCallback"]);
return (
logFrontendError(error)}
- fallback={({ error }) =>
- isUnauthorizedError(error) ? <>> :
- }
+ fallback={() => }
>
-
- }>
- {match(route)
- .with({ name: "PopupCallback" }, () => )
- .with({ name: "ProjectLogin" }, ({ params: { sessionExpired } }) =>
- projectConfiguration.match({
- None: () => ,
- Some: ({ projectId }) => (
-
- ),
- }),
- )
- .with({ name: "AccountArea" }, { name: "ProjectRootRedirect" }, route =>
- match(route)
- .with({ name: "AccountArea" }, ({ params: { accountMembershipId } }) => (
-
- ))
- .with({ name: "ProjectRootRedirect" }, ({ params: { to, source } }) => (
-
- ))
- .exhaustive(),
- )
- .with(P.nullish, () => )
- .exhaustive()}
-
-
+ {match(route)
+ // The callback page is agnostic as to the current authentication,
+ // meaning we don't check if the user is logged in when on this path
+ .with({ name: "PopupCallback" }, () => )
+ .otherwise(() => (
+ // The auth check requires a GraphQL client
+
+
+
+
+ ))}
diff --git a/clients/banking/src/components/AccountArea.tsx b/clients/banking/src/components/AccountArea.tsx
index 793177db3..49126a7af 100644
--- a/clients/banking/src/components/AccountArea.tsx
+++ b/clients/banking/src/components/AccountArea.tsx
@@ -1,4 +1,4 @@
-import { Option, Result } from "@swan-io/boxed";
+import { Array, Dict, Option } from "@swan-io/boxed";
import { AutoWidthImage } from "@swan-io/lake/src/components/AutoWidthImage";
import { Box } from "@swan-io/lake/src/components/Box";
import { ErrorBoundary } from "@swan-io/lake/src/components/ErrorBoundary";
@@ -23,10 +23,7 @@ import { insets } from "@swan-io/lake/src/constants/insets";
import { useBoolean } from "@swan-io/lake/src/hooks/useBoolean";
import { usePersistedState } from "@swan-io/lake/src/hooks/usePersistedState";
import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive";
-import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation";
-import { isEmpty, isNotEmpty, isNullish } from "@swan-io/lake/src/utils/nullish";
-import { useQueryWithErrorBoundary } from "@swan-io/lake/src/utils/urql";
-import { Request } from "@swan-io/request";
+import { isNotEmpty, isNullish } from "@swan-io/lake/src/utils/nullish";
import { CONTENT_ID, SkipToContent } from "@swan-io/shared-business/src/components/SkipToContent";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -38,13 +35,8 @@ import {
View,
} from "react-native";
import { P, match } from "ts-pattern";
-import { useQuery } from "urql";
import logoSwan from "../assets/images/logo-swan.svg";
-import {
- AccountAreaDocument,
- LastRelevantIdentificationDocument,
- UpdateAccountLanguageDocument,
-} from "../graphql/partner";
+import { AccountAreaQuery, IdentificationFragment } from "../graphql/partner";
import { AccountActivationPage } from "../pages/AccountActivationPage";
import { AccountNotFoundPage, NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
@@ -54,7 +46,6 @@ import { getIdentificationLevelStatusInfo } from "../utils/identification";
import { logFrontendError, setSentryUser } from "../utils/logger";
import { projectConfiguration } from "../utils/projectId";
import {
- RouteName,
Router,
accountMinimalRoutes,
historyMenuRoutes,
@@ -62,7 +53,6 @@ import {
} from "../utils/routes";
import { signout } from "../utils/signout";
import { updateTgglContext } from "../utils/tggl";
-import { isUnauthorizedError } from "../utils/urql";
import { AccountDetailsArea } from "./AccountDetailsArea";
import { AccountNavigation, Menu } from "./AccountNavigation";
import { AccountActivationTag, AccountPicker, AccountPickerButton } from "./AccountPicker";
@@ -150,11 +140,55 @@ const styles = StyleSheet.create({
type Props = {
accountMembershipId: string;
+ accountMembership: NonNullable;
+ user: NonNullable;
+ projectInfo: NonNullable;
+ lastRelevantIdentification: Option;
+ shouldDisplayIdVerification: boolean;
+ requireFirstTransfer: boolean;
+ permissions: {
+ canInitiatePayments: boolean;
+ canManageBeneficiaries: boolean;
+ canManageCards: boolean;
+ canViewAccount: boolean;
+ canManageAccountMembership: boolean;
+ };
+ features: {
+ accountStatementsVisible: boolean;
+ accountVisible: boolean;
+ transferCreationVisible: boolean;
+ paymentListVisible: boolean;
+ virtualIbansVisible: boolean;
+ memberCreationVisible: boolean;
+ memberListVisible: boolean;
+ physicalCardOrderVisible: boolean;
+ virtualCardOrderVisible: boolean;
+ };
+ activationTag: AccountActivationTag;
+ sections: {
+ history: boolean;
+ account: boolean;
+ transfer: boolean;
+ cards: boolean;
+ members: boolean;
+ };
+ reload: () => void;
};
-const COOKIE_REFRESH_INTERVAL = 30000; // 30s
-
-export const AccountArea = ({ accountMembershipId }: Props) => {
+export const AccountArea = ({
+ accountMembershipId,
+ accountMembership,
+ projectInfo,
+ user,
+ sections,
+ features,
+ activationTag,
+ lastRelevantIdentification,
+ shouldDisplayIdVerification,
+ requireFirstTransfer,
+ permissions,
+ reload,
+}: Props) => {
const { desktop } = useResponsive();
const [isScrolled, setIsScrolled] = useState(false);
@@ -167,169 +201,21 @@ export const AccountArea = ({ accountMembershipId }: Props) => {
setIsScrolled(event.nativeEvent.contentOffset.y > 0);
}, []);
- // Call API to extend cookie TTL
- useEffect(() => {
- const intervalId = setInterval(() => {
- Request.make({ url: "/api/ping", method: "POST", withCredentials: true });
- }, COOKIE_REFRESH_INTERVAL);
-
- return () => clearInterval(intervalId);
- }, []);
-
- const [
- {
- data: { accountMembership, projectInfo, user },
- },
- reexecuteQuery,
- ] = useQueryWithErrorBoundary({
- query: AccountAreaDocument,
- variables: { accountMembershipId },
- });
-
- const hasRequiredIdentificationLevel =
- accountMembership?.hasRequiredIdentificationLevel ?? undefined;
-
- // We have to perform a cascading request here because the `recommendedIdentificationLevel`
- // is only available once the account membership is loaded.
- // This is acceptable because it only triggers the query if necessary.
- const [{ data: lastRelevantIdentificationData }] = useQuery({
- query: LastRelevantIdentificationDocument,
- pause: hasRequiredIdentificationLevel !== false,
- variables: {
- accountMembershipId,
- identificationProcess: accountMembership?.recommendedIdentificationLevel,
- },
- });
-
- const lastRelevantIdentification = Option.fromNullable(
- lastRelevantIdentificationData?.accountMembership?.user?.identifications?.edges?.[0]?.node,
- );
-
- const [, updateAccountLanguage] = useUrqlMutation(UpdateAccountLanguageDocument);
-
- const accountId = accountMembership?.account?.id;
- const language = accountMembership?.account?.language;
- const iban = accountMembership?.account?.IBAN;
- const bankDetails = accountMembership?.account?.bankDetails;
-
- useEffect(() => {
- // Triggers `bankDetails` generation if not yet available
- if (accountId != null && language != null && iban != null && bankDetails == null) {
- const future = updateAccountLanguage({ id: accountId, language });
- return () => future.cancel();
- }
- }, [accountId, language, iban, bankDetails, updateAccountLanguage]);
-
- const currentAccountMembership = useMemo(
- () => Option.fromNullable(accountMembership).toResult(new Error("NoAccountMembership")),
- [accountMembership],
- );
-
- const hasMultipleMemberships = currentAccountMembership
- .toOption()
- .flatMap(data => Option.fromNullable(data.user))
+ const hasMultipleMemberships = Option.fromNullable(accountMembership.user)
.map(({ accountMemberships: { totalCount } }) => totalCount > 1)
.getWithDefault(false);
- const account = accountMembership?.account;
+ const account = accountMembership.account;
const accountCountry = account?.country ?? undefined;
const holder = account?.holder;
const isIndividual = holder?.info.__typename === "AccountHolderIndividualInfo";
- const hasTransactions = (account?.transactions?.totalCount ?? 0) >= 1;
-
- const requireFirstTransfer = match({ account, user })
- .with(
- { account: { country: "FRA" }, user: { identificationLevels: { PVID: false, QES: false } } },
- () => true,
- )
- .with(
- { account: { country: "ESP" }, user: { identificationLevels: { QES: false } } },
- () => true,
- )
- .with({ account: { country: "DEU" } }, () => true)
- .otherwise(() => false);
-
- const { supportingDocumentSettings } = projectInfo;
- const documentCollectMode = supportingDocumentSettings?.collectMode;
-
- const documentCollection = holder?.supportingDocumentCollections.edges[0]?.node;
- const documentCollectionStatus = documentCollection?.statusInfo.status;
const userId = user?.id ?? "";
const firstName = user?.firstName ?? "";
const lastName = user?.lastName ?? "";
const phoneNumber = user?.mobilePhoneNumber ?? "";
- const activationTag = match({
- documentCollectionStatus,
- documentCollectMode,
- hasTransactions,
- identificationStatusInfo: lastRelevantIdentification.map(getIdentificationLevelStatusInfo),
- accountHolderType: account?.holder.info.__typename,
- verificationStatus: account?.holder.verificationStatus,
- isIndividual,
- requireFirstTransfer,
- isLegalRepresentative: accountMembership?.legalRepresentative ?? false,
- account,
- })
- .returnType()
- // if payment level limitations have been lifted, no need for activation
- .with({ verificationStatus: "Refused", isLegalRepresentative: true }, () => "refused")
- .with(
- { account: { paymentLevel: "Unlimited", paymentAccountType: "PaymentService" } },
- () => "none",
- )
- // never show to non-legal rep memberships
- .with({ isLegalRepresentative: false }, () => "none")
- .with({ identificationStatusInfo: Option.P.Some({ status: "Pending" }) }, () => "pending")
- .with(
- { identificationStatusInfo: Option.P.Some({ status: P.not("Valid") }) },
- () => "actionRequired",
- )
- .with(
- { documentCollectionStatus: "PendingReview", accountHolderType: "AccountHolderCompanyInfo" },
- () => "pending",
- )
- .with(
- {
- documentCollectionStatus: P.not("Approved"),
- accountHolderType: "AccountHolderCompanyInfo",
- },
- () => "actionRequired",
- )
- .with(
- {
- isIndividual: true,
- requireFirstTransfer: false,
- account: {
- holder: { verificationStatus: P.union("NotStarted", "Pending") },
- },
- },
- {
- isIndividual: true,
- requireFirstTransfer: false,
- documentCollectMode: "API",
- account: {
- holder: { verificationStatus: P.union("Pending", "WaitingForInformation") },
- },
- },
- () => "pending",
- )
- .with(
- {
- isIndividual: true,
- requireFirstTransfer: false,
- account: { holder: { verificationStatus: "Verified" } },
- },
- () => "none",
- )
- .with(
- { isIndividual: true, requireFirstTransfer: true, hasTransactions: false },
- () => "actionRequired",
- )
- .otherwise(() => "none");
-
const [, setAccountMembershipState] = usePersistedState(
`swan_session_webBankingAccountMembershipState${projectConfiguration
.map(({ projectId }) => `_${projectId}`)
@@ -338,203 +224,89 @@ export const AccountArea = ({ accountMembershipId }: Props) => {
);
useEffect(() => {
- match(currentAccountMembership)
- .with(Result.P.Ok({ id: P.select(), user: { id: user?.id } }), accountMembershipId =>
- setAccountMembershipState({ accountMembershipId }),
- )
- .otherwise(() => setAccountMembershipState({}));
- }, [setAccountMembershipState, currentAccountMembership, user]);
-
- const refetchAccountAreaQuery = useCallback(() => {
- reexecuteQuery({ requestPolicy: "network-only" });
- }, [reexecuteQuery]);
+ setAccountMembershipState({ accountMembershipId });
+ }, [setAccountMembershipState, accountMembershipId]);
useEffect(() => {
- if (userId) {
- const sentryUser: Record = { id: userId };
-
- firstName && (sentryUser["firstName"] = firstName);
- lastName && (sentryUser["lastName"] = lastName);
- phoneNumber && (sentryUser["phoneNumber"] = phoneNumber);
-
- setSentryUser(sentryUser);
- } else {
- setSentryUser(null);
- }
- }, [firstName, lastName, phoneNumber, userId]);
-
- const settings = projectInfo.webBankingSettings;
-
- const accountStatementsVisible = Boolean(settings?.accountStatementsVisible);
- const accountVisible = Boolean(settings?.accountVisible);
- const transferCreationVisible = Boolean(settings?.transferCreationVisible);
- const paymentListVisible = Boolean(settings?.paymentListVisible);
- const virtualIbansVisible = Boolean(settings?.virtualIbansVisible);
-
- const memberCreationVisible = Boolean(settings?.memberCreationVisible);
- const memberListVisible = Boolean(settings?.memberListVisible);
-
- const physicalCardOrderVisible = Boolean(settings?.physicalCardOrderVisible);
- const virtualCardOrderVisible = Boolean(settings?.virtualCardOrderVisible);
- const cardOrderVisible = physicalCardOrderVisible || virtualCardOrderVisible;
-
- const B2BMembershipIDVerification = projectInfo.B2BMembershipIDVerification;
-
- const membership = useMemo(
- () =>
- currentAccountMembership.map(accountMembership => {
- const { canInitiatePayments, canManageBeneficiaries, canManageCards, canViewAccount } =
- accountMembership;
-
- const membershipEnabled = accountMembership.statusInfo.status === "Enabled";
- const canManageAccountMembership =
- accountMembership.canManageAccountMembership && membershipEnabled;
- const canAddCard = canViewAccount && canManageCards;
-
- // identity verication is removed for permission-less membership
- // if the project has a disabled `B2BMembershipIDVerification`
- const shouldDisplayIdVerification = !(
- B2BMembershipIDVerification === false &&
- accountMembership.canManageAccountMembership === false &&
- accountMembership.canInitiatePayments === false &&
- accountMembership.canManageBeneficiaries === false &&
- accountMembership.canManageCards === false
- );
-
- return {
- accountMembership,
- canManageAccountMembership,
- canInitiatePayments,
- canManageBeneficiaries,
- canAddCard,
- canManageCards,
-
- shouldDisplayIdVerification,
-
- historyMenuIsVisible: canViewAccount,
- detailsMenuIsVisible: canViewAccount && accountVisible,
-
- paymentMenuIsVisible:
- canViewAccount &&
- canInitiatePayments &&
- membershipEnabled &&
- (transferCreationVisible || paymentListVisible),
-
- // In case the user doesn't have the right to manage cards
- // but has one attached to the current membership
- cardMenuIsVisible:
- accountMembership.allCards.totalCount > 0 || (canAddCard && cardOrderVisible),
-
- memberMenuIsVisible: canViewAccount && canManageAccountMembership && memberListVisible,
- };
- }),
- [
- currentAccountMembership,
- accountVisible,
- paymentListVisible,
- cardOrderVisible,
- memberListVisible,
- transferCreationVisible,
- B2BMembershipIDVerification,
- ],
- );
+ setSentryUser({
+ id: user.id,
+ firstName: user.firstName ?? undefined,
+ lastName: user.lastName ?? undefined,
+ phoneNumber: user.mobilePhoneNumber ?? undefined,
+ });
+ }, [user]);
const accentColor = projectInfo.accentColor ?? invariantColors.defaultAccentColor;
const projectName = projectInfo.name;
const projectLogo = projectInfo.logoUri ?? undefined;
- const menu = membership
- .map