@remit/web-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthCard,
|
|
3
|
+
AuthFooter,
|
|
4
|
+
AuthHero,
|
|
5
|
+
Banner,
|
|
6
|
+
Button,
|
|
7
|
+
Input,
|
|
8
|
+
} from "@remit/ui";
|
|
9
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
10
|
+
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
11
|
+
import { FatalErrorOverlay } from "@/components/ui/FatalErrorOverlay";
|
|
12
|
+
import { authFooterNote } from "./account-menu-mode";
|
|
13
|
+
import {
|
|
14
|
+
type AuthRequest,
|
|
15
|
+
authInlineMessage,
|
|
16
|
+
classifyAuthError,
|
|
17
|
+
reportFatalAuthError,
|
|
18
|
+
} from "./auth-error";
|
|
19
|
+
import {
|
|
20
|
+
authClient,
|
|
21
|
+
isBetterAuthEnabled,
|
|
22
|
+
isSignUpDisabledError,
|
|
23
|
+
} from "./better-auth-config";
|
|
24
|
+
|
|
25
|
+
type Mode = "signIn" | "signUp";
|
|
26
|
+
|
|
27
|
+
interface BetterAuthShellProps {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SIGN_UP_REQUEST: AuthRequest = {
|
|
32
|
+
method: "POST",
|
|
33
|
+
path: "/api/auth/sign-up/email",
|
|
34
|
+
};
|
|
35
|
+
const SIGN_IN_REQUEST: AuthRequest = {
|
|
36
|
+
method: "POST",
|
|
37
|
+
path: "/api/auth/sign-in/email",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const SignInForm = () => {
|
|
41
|
+
const [mode, setMode] = useState<Mode>("signIn");
|
|
42
|
+
const [name, setName] = useState("");
|
|
43
|
+
const [email, setEmail] = useState("");
|
|
44
|
+
const [password, setPassword] = useState("");
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const [submitting, setSubmitting] = useState(false);
|
|
47
|
+
// Learned from the server's rejection, never a client-side flag — the UI
|
|
48
|
+
// cannot disagree with better-auth's `disableSignUp`.
|
|
49
|
+
const [signUpClosed, setSignUpClosed] = useState(false);
|
|
50
|
+
|
|
51
|
+
const activeMode: Mode = signUpClosed ? "signIn" : mode;
|
|
52
|
+
|
|
53
|
+
const handleFailure = (failure: unknown, request: AuthRequest) => {
|
|
54
|
+
if (isSignUpDisabledError(failure)) {
|
|
55
|
+
setSignUpClosed(true);
|
|
56
|
+
setMode("signIn");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const kind = classifyAuthError(failure);
|
|
60
|
+
if (kind === "fatal") {
|
|
61
|
+
reportFatalAuthError(failure, request);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setError(authInlineMessage(failure, kind));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const submit = async (event: FormEvent) => {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
if (submitting) return;
|
|
70
|
+
setError(null);
|
|
71
|
+
setSubmitting(true);
|
|
72
|
+
|
|
73
|
+
const request = activeMode === "signUp" ? SIGN_UP_REQUEST : SIGN_IN_REQUEST;
|
|
74
|
+
// better-auth returns `{ error }` for a server rejection, but a network
|
|
75
|
+
// failure rejects the promise — fold both into one failure value so a
|
|
76
|
+
// broken route or contract never vanishes as an unhandled rejection.
|
|
77
|
+
const failure: unknown = await (activeMode === "signUp"
|
|
78
|
+
? authClient.signUp.email({ email, password, name })
|
|
79
|
+
: authClient.signIn.email({ email, password })
|
|
80
|
+
).then(
|
|
81
|
+
(result) => result.error ?? null,
|
|
82
|
+
(thrown: unknown) => thrown,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
setSubmitting(false);
|
|
86
|
+
// On success `failure` is null; better-auth's session store updates and
|
|
87
|
+
// useSession re-renders the gate into the app.
|
|
88
|
+
if (failure) handleFailure(failure, request);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<FatalErrorOverlay />
|
|
94
|
+
<AuthCard>
|
|
95
|
+
<AuthHero />
|
|
96
|
+
<form
|
|
97
|
+
onSubmit={submit}
|
|
98
|
+
className="mt-6 flex flex-col gap-4 rounded-lg border border-line bg-surface p-7 shadow-lg"
|
|
99
|
+
>
|
|
100
|
+
{signUpClosed ? (
|
|
101
|
+
<Banner tone="info">
|
|
102
|
+
New signups are currently closed. Sign in with an existing
|
|
103
|
+
account, or contact your administrator for access.
|
|
104
|
+
</Banner>
|
|
105
|
+
) : (
|
|
106
|
+
<div className="flex gap-1">
|
|
107
|
+
<Button
|
|
108
|
+
size="sm"
|
|
109
|
+
variant={activeMode === "signIn" ? "secondary" : "ghost"}
|
|
110
|
+
aria-pressed={activeMode === "signIn"}
|
|
111
|
+
onClick={() => setMode("signIn")}
|
|
112
|
+
>
|
|
113
|
+
Sign in
|
|
114
|
+
</Button>
|
|
115
|
+
<Button
|
|
116
|
+
size="sm"
|
|
117
|
+
variant={activeMode === "signUp" ? "secondary" : "ghost"}
|
|
118
|
+
aria-pressed={activeMode === "signUp"}
|
|
119
|
+
onClick={() => setMode("signUp")}
|
|
120
|
+
>
|
|
121
|
+
Create account
|
|
122
|
+
</Button>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{error && <Banner tone="danger">{error}</Banner>}
|
|
127
|
+
|
|
128
|
+
{activeMode === "signUp" && (
|
|
129
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
130
|
+
<label htmlFor="ba-name" className="text-fg-muted">
|
|
131
|
+
Name
|
|
132
|
+
</label>
|
|
133
|
+
<Input
|
|
134
|
+
id="ba-name"
|
|
135
|
+
type="text"
|
|
136
|
+
autoComplete="name"
|
|
137
|
+
value={name}
|
|
138
|
+
onChange={(e) => setName(e.target.value)}
|
|
139
|
+
placeholder="Your name"
|
|
140
|
+
required
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
146
|
+
<label htmlFor="ba-email" className="text-fg-muted">
|
|
147
|
+
Email
|
|
148
|
+
</label>
|
|
149
|
+
<Input
|
|
150
|
+
id="ba-email"
|
|
151
|
+
type="email"
|
|
152
|
+
autoComplete="email"
|
|
153
|
+
value={email}
|
|
154
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
155
|
+
placeholder="you@example.com"
|
|
156
|
+
required
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
161
|
+
<label htmlFor="ba-password" className="text-fg-muted">
|
|
162
|
+
Password
|
|
163
|
+
</label>
|
|
164
|
+
<Input
|
|
165
|
+
id="ba-password"
|
|
166
|
+
type="password"
|
|
167
|
+
autoComplete={
|
|
168
|
+
activeMode === "signUp" ? "new-password" : "current-password"
|
|
169
|
+
}
|
|
170
|
+
value={password}
|
|
171
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
172
|
+
placeholder="Your password"
|
|
173
|
+
required
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<Button type="submit">
|
|
178
|
+
{activeMode === "signUp" ? "Create account" : "Sign in"}
|
|
179
|
+
</Button>
|
|
180
|
+
</form>
|
|
181
|
+
<AuthFooter
|
|
182
|
+
note={authFooterNote({
|
|
183
|
+
betterAuthEnabled: isBetterAuthEnabled(),
|
|
184
|
+
cognitoConfigured: false,
|
|
185
|
+
})}
|
|
186
|
+
/>
|
|
187
|
+
</AuthCard>
|
|
188
|
+
</>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const BetterAuthShell = ({ children }: BetterAuthShellProps) => {
|
|
193
|
+
const { data: session, isPending } = authClient.useSession();
|
|
194
|
+
|
|
195
|
+
if (isPending) return <AppShellSkeleton />;
|
|
196
|
+
if (session) return <>{children}</>;
|
|
197
|
+
return <SignInForm />;
|
|
198
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { COGNITO_FOOTER_NOTE } from "@remit/ui";
|
|
4
|
+
import { accountMenuMode, authFooterNote } from "./account-menu-mode";
|
|
5
|
+
|
|
6
|
+
describe("accountMenuMode", () => {
|
|
7
|
+
test("prefers better-auth when enabled", () => {
|
|
8
|
+
assert.equal(
|
|
9
|
+
accountMenuMode({ betterAuthEnabled: true, cognitoConfigured: false }),
|
|
10
|
+
"betterAuth",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("better-auth wins even when cognito is also configured", () => {
|
|
15
|
+
assert.equal(
|
|
16
|
+
accountMenuMode({ betterAuthEnabled: true, cognitoConfigured: true }),
|
|
17
|
+
"betterAuth",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("falls back to cognito when only cognito is configured", () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
accountMenuMode({ betterAuthEnabled: false, cognitoConfigured: true }),
|
|
24
|
+
"cognito",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("renders nothing when neither identity provider is active", () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
accountMenuMode({ betterAuthEnabled: false, cognitoConfigured: false }),
|
|
31
|
+
"none",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("authFooterNote", () => {
|
|
37
|
+
test("names Cognito only when Cognito is the active provider", () => {
|
|
38
|
+
assert.equal(
|
|
39
|
+
authFooterNote({ betterAuthEnabled: false, cognitoConfigured: true }),
|
|
40
|
+
COGNITO_FOOTER_NOTE,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stays provider-neutral in better-auth mode", () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
authFooterNote({ betterAuthEnabled: true, cognitoConfigured: false }),
|
|
47
|
+
undefined,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("stays provider-neutral when better-auth wins over present cognito env", () => {
|
|
52
|
+
assert.equal(
|
|
53
|
+
authFooterNote({ betterAuthEnabled: true, cognitoConfigured: true }),
|
|
54
|
+
undefined,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("stays provider-neutral when no provider is configured", () => {
|
|
59
|
+
assert.equal(
|
|
60
|
+
authFooterNote({ betterAuthEnabled: false, cognitoConfigured: false }),
|
|
61
|
+
undefined,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { COGNITO_FOOTER_NOTE } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
export type AccountMenuMode = "betterAuth" | "cognito" | "none";
|
|
4
|
+
|
|
5
|
+
export interface AccountMenuModeInput {
|
|
6
|
+
betterAuthEnabled: boolean;
|
|
7
|
+
cognitoConfigured: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const accountMenuMode = ({
|
|
11
|
+
betterAuthEnabled,
|
|
12
|
+
cognitoConfigured,
|
|
13
|
+
}: AccountMenuModeInput): AccountMenuMode => {
|
|
14
|
+
if (betterAuthEnabled) return "betterAuth";
|
|
15
|
+
if (cognitoConfigured) return "cognito";
|
|
16
|
+
return "none";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The sign-in footer caption names Cognito only when Cognito is the active
|
|
21
|
+
* provider. Every other mode falls through to the kit's provider-neutral
|
|
22
|
+
* default, so the caption can never claim an identity provider the build does
|
|
23
|
+
* not use.
|
|
24
|
+
*/
|
|
25
|
+
export const authFooterNote = (
|
|
26
|
+
input: AccountMenuModeInput,
|
|
27
|
+
): string | undefined =>
|
|
28
|
+
accountMenuMode(input) === "cognito" ? COGNITO_FOOTER_NOTE : undefined;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { beforeEach, describe, test } from "node:test";
|
|
3
|
+
|
|
4
|
+
type TestConfig = {
|
|
5
|
+
cognito?: {
|
|
6
|
+
userPoolId?: string;
|
|
7
|
+
clientId?: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
region?: string;
|
|
10
|
+
};
|
|
11
|
+
appOrigin?: string;
|
|
12
|
+
};
|
|
13
|
+
type AmplifyCall = {
|
|
14
|
+
Auth?: {
|
|
15
|
+
Cognito?: {
|
|
16
|
+
userPoolId?: string;
|
|
17
|
+
userPoolClientId?: string;
|
|
18
|
+
loginWith?: {
|
|
19
|
+
oauth?: {
|
|
20
|
+
domain?: string;
|
|
21
|
+
redirectSignIn?: string[];
|
|
22
|
+
redirectSignOut?: string[];
|
|
23
|
+
responseType?: string;
|
|
24
|
+
scopes?: string[];
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
declare global {
|
|
32
|
+
// eslint-disable-next-line no-var
|
|
33
|
+
var __AMPLIFY_MOCKS__: { configureCalls: AmplifyCall[] } | undefined;
|
|
34
|
+
// eslint-disable-next-line no-var
|
|
35
|
+
var __CACHE_BUST__: number | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let cacheBust = 0;
|
|
39
|
+
|
|
40
|
+
const loadModule = async (config: TestConfig) => {
|
|
41
|
+
cacheBust += 1;
|
|
42
|
+
globalThis.__CACHE_BUST__ = cacheBust;
|
|
43
|
+
globalThis.__REMIT_CONFIG__ = config;
|
|
44
|
+
globalThis.__AMPLIFY_MOCKS__ = { configureCalls: [] };
|
|
45
|
+
return import(`./amplify-config.ts?v=${cacheBust}`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const fullConfig: TestConfig = {
|
|
49
|
+
cognito: {
|
|
50
|
+
userPoolId: "us-east-1_pool",
|
|
51
|
+
clientId: "client-id",
|
|
52
|
+
domain: "example.auth.us-east-1.amazoncognito.com",
|
|
53
|
+
region: "us-east-1",
|
|
54
|
+
},
|
|
55
|
+
appOrigin: "https://app.example.com",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("isCognitoConfigured", () => {
|
|
59
|
+
test("returns true when userPoolId and clientId are present", async () => {
|
|
60
|
+
const mod = await loadModule(fullConfig);
|
|
61
|
+
assert.equal(mod.isCognitoConfigured(), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("returns false when both values are missing", async () => {
|
|
65
|
+
const mod = await loadModule({});
|
|
66
|
+
assert.equal(mod.isCognitoConfigured(), false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns false when only userPoolId is set", async () => {
|
|
70
|
+
const mod = await loadModule({ cognito: { userPoolId: "pool" } });
|
|
71
|
+
assert.equal(mod.isCognitoConfigured(), false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns false when only clientId is set", async () => {
|
|
75
|
+
const mod = await loadModule({ cognito: { clientId: "client" } });
|
|
76
|
+
assert.equal(mod.isCognitoConfigured(), false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("configureAmplify", () => {
|
|
81
|
+
let originalWarn: typeof console.warn;
|
|
82
|
+
let warnings: unknown[][];
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
originalWarn = console.warn;
|
|
86
|
+
warnings = [];
|
|
87
|
+
console.warn = (...args: unknown[]) => {
|
|
88
|
+
warnings.push(args);
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("warns and does not call Amplify.configure when unconfigured", async () => {
|
|
93
|
+
const mod = await loadModule({});
|
|
94
|
+
mod.configureAmplify();
|
|
95
|
+
console.warn = originalWarn;
|
|
96
|
+
assert.equal(globalThis.__AMPLIFY_MOCKS__?.configureCalls.length, 0);
|
|
97
|
+
assert.equal(warnings.length, 1);
|
|
98
|
+
const message = String(warnings[0][0]);
|
|
99
|
+
assert.ok(
|
|
100
|
+
message.includes("Cognito"),
|
|
101
|
+
`expected warning to mention Cognito, got: ${message}`,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("passes userPoolId and clientId to Amplify.configure", async () => {
|
|
106
|
+
const mod = await loadModule(fullConfig);
|
|
107
|
+
mod.configureAmplify();
|
|
108
|
+
console.warn = originalWarn;
|
|
109
|
+
const calls = globalThis.__AMPLIFY_MOCKS__?.configureCalls ?? [];
|
|
110
|
+
assert.equal(calls.length, 1);
|
|
111
|
+
const cognito = calls[0]?.Auth?.Cognito;
|
|
112
|
+
assert.equal(cognito?.userPoolId, "us-east-1_pool");
|
|
113
|
+
assert.equal(cognito?.userPoolClientId, "client-id");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("wires OAuth block with appOrigin in redirect arrays when domain set", async () => {
|
|
117
|
+
const mod = await loadModule(fullConfig);
|
|
118
|
+
mod.configureAmplify();
|
|
119
|
+
console.warn = originalWarn;
|
|
120
|
+
const oauth =
|
|
121
|
+
globalThis.__AMPLIFY_MOCKS__?.configureCalls[0]?.Auth?.Cognito?.loginWith
|
|
122
|
+
?.oauth;
|
|
123
|
+
assert.ok(oauth, "expected oauth block to be present");
|
|
124
|
+
assert.equal(oauth?.domain, "example.auth.us-east-1.amazoncognito.com");
|
|
125
|
+
assert.deepEqual(oauth?.redirectSignIn, ["https://app.example.com"]);
|
|
126
|
+
assert.deepEqual(oauth?.redirectSignOut, ["https://app.example.com"]);
|
|
127
|
+
assert.equal(oauth?.responseType, "code");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("omits loginWith block when no domain configured", async () => {
|
|
131
|
+
const mod = await loadModule({
|
|
132
|
+
cognito: { userPoolId: "pool", clientId: "client" },
|
|
133
|
+
});
|
|
134
|
+
mod.configureAmplify();
|
|
135
|
+
console.warn = originalWarn;
|
|
136
|
+
const cognito =
|
|
137
|
+
globalThis.__AMPLIFY_MOCKS__?.configureCalls[0]?.Auth?.Cognito;
|
|
138
|
+
assert.equal(cognito?.loginWith, undefined);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("uses empty redirect arrays when domain present but appOrigin missing", async () => {
|
|
142
|
+
const mod = await loadModule({
|
|
143
|
+
cognito: {
|
|
144
|
+
userPoolId: "pool",
|
|
145
|
+
clientId: "client",
|
|
146
|
+
domain: "example.auth.us-east-1.amazoncognito.com",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
mod.configureAmplify();
|
|
150
|
+
console.warn = originalWarn;
|
|
151
|
+
const oauth =
|
|
152
|
+
globalThis.__AMPLIFY_MOCKS__?.configureCalls[0]?.Auth?.Cognito?.loginWith
|
|
153
|
+
?.oauth;
|
|
154
|
+
assert.ok(oauth);
|
|
155
|
+
assert.deepEqual(oauth?.redirectSignIn, []);
|
|
156
|
+
assert.deepEqual(oauth?.redirectSignOut, []);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Amplify } from "aws-amplify";
|
|
2
|
+
import { getRuntimeConfig } from "../runtime-config";
|
|
3
|
+
|
|
4
|
+
export interface CognitoEnv {
|
|
5
|
+
userPoolId: string;
|
|
6
|
+
userPoolClientId: string;
|
|
7
|
+
domain: string;
|
|
8
|
+
region: string;
|
|
9
|
+
appOrigin: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const readCognitoEnv = (): CognitoEnv | null => {
|
|
13
|
+
const config = getRuntimeConfig();
|
|
14
|
+
const { userPoolId, clientId, domain, region } = config.cognito;
|
|
15
|
+
|
|
16
|
+
if (!userPoolId || !clientId) return null;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
userPoolId,
|
|
20
|
+
userPoolClientId: clientId,
|
|
21
|
+
domain,
|
|
22
|
+
region,
|
|
23
|
+
appOrigin: config.appOrigin,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const cognitoEnv = readCognitoEnv();
|
|
28
|
+
|
|
29
|
+
export const isCognitoConfigured = (): boolean => cognitoEnv !== null;
|
|
30
|
+
|
|
31
|
+
export const configureAmplify = (): void => {
|
|
32
|
+
if (!cognitoEnv) {
|
|
33
|
+
console.warn(
|
|
34
|
+
"[auth] Cognito user pool is not configured. Running in local dev mode without Cognito auth.",
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { userPoolId, userPoolClientId, domain, appOrigin } = cognitoEnv;
|
|
40
|
+
|
|
41
|
+
Amplify.configure({
|
|
42
|
+
Auth: {
|
|
43
|
+
Cognito: {
|
|
44
|
+
userPoolId,
|
|
45
|
+
userPoolClientId,
|
|
46
|
+
loginWith: domain
|
|
47
|
+
? {
|
|
48
|
+
oauth: {
|
|
49
|
+
domain,
|
|
50
|
+
scopes: ["openid", "email", "profile"],
|
|
51
|
+
redirectSignIn: appOrigin ? [appOrigin] : [],
|
|
52
|
+
redirectSignOut: appOrigin ? [appOrigin] : [],
|
|
53
|
+
responseType: "code",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
: undefined,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
|
|
4
|
+
import {
|
|
5
|
+
type AuthRequest,
|
|
6
|
+
authInlineMessage,
|
|
7
|
+
buildFatalAuthMessage,
|
|
8
|
+
classifyAuthError,
|
|
9
|
+
reportFatalAuthError,
|
|
10
|
+
} from "./auth-error";
|
|
11
|
+
|
|
12
|
+
const SIGN_UP: AuthRequest = {
|
|
13
|
+
method: "POST",
|
|
14
|
+
path: "/api/auth/sign-up/email",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("classifyAuthError", () => {
|
|
18
|
+
it("treats a 404 on our own route as fatal", () => {
|
|
19
|
+
assert.equal(
|
|
20
|
+
classifyAuthError({ status: 404, statusText: "Not Found" }),
|
|
21
|
+
"fatal",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("treats a 5xx as fatal", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
classifyAuthError({ status: 503, statusText: "Service Unavailable" }),
|
|
28
|
+
"fatal",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("treats an unexpected 4xx with no message as fatal", () => {
|
|
33
|
+
assert.equal(classifyAuthError({ status: 418 }), "fatal");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("treats an expected, server-described 4xx as validation", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
classifyAuthError({
|
|
39
|
+
status: 422,
|
|
40
|
+
code: "USER_ALREADY_EXISTS",
|
|
41
|
+
message: "User already exists.",
|
|
42
|
+
}),
|
|
43
|
+
"validation",
|
|
44
|
+
);
|
|
45
|
+
assert.equal(
|
|
46
|
+
classifyAuthError({
|
|
47
|
+
status: 401,
|
|
48
|
+
code: "INVALID_EMAIL_OR_PASSWORD",
|
|
49
|
+
message: "Invalid email or password.",
|
|
50
|
+
}),
|
|
51
|
+
"validation",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("treats a statusless failure as network", () => {
|
|
56
|
+
assert.equal(classifyAuthError(new Error("Failed to fetch")), "network");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("treats better-auth's Fetch Error sentinel as network", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
classifyAuthError({
|
|
62
|
+
status: 500,
|
|
63
|
+
statusText: "Fetch Error",
|
|
64
|
+
message: "Fetch related error.",
|
|
65
|
+
}),
|
|
66
|
+
"network",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("authInlineMessage", () => {
|
|
72
|
+
it("surfaces the server's message for a validation error", () => {
|
|
73
|
+
assert.equal(
|
|
74
|
+
authInlineMessage(
|
|
75
|
+
{ status: 422, message: "User already exists." },
|
|
76
|
+
"validation",
|
|
77
|
+
),
|
|
78
|
+
"User already exists.",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("gives a connection hint for a network error", () => {
|
|
83
|
+
assert.match(authInlineMessage(new Error("boom"), "network"), /offline/i);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("buildFatalAuthMessage", () => {
|
|
88
|
+
it("names the method, path, and status, with 404 troubleshooting guidance", () => {
|
|
89
|
+
const message = buildFatalAuthMessage(
|
|
90
|
+
{ status: 404, statusText: "Not Found" },
|
|
91
|
+
SIGN_UP,
|
|
92
|
+
);
|
|
93
|
+
assert.match(message, /POST \/api\/auth\/sign-up\/email/);
|
|
94
|
+
assert.match(message, /404 Not Found/);
|
|
95
|
+
assert.match(message, /backend is running and up to date/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("reportFatalAuthError", () => {
|
|
100
|
+
afterEach(() => __resetFatalError());
|
|
101
|
+
|
|
102
|
+
it("escalates to a non-recoverable fatal carrying the request detail", () => {
|
|
103
|
+
reportFatalAuthError({ status: 404, statusText: "Not Found" }, SIGN_UP);
|
|
104
|
+
const fatal = getCurrentFatalError();
|
|
105
|
+
assert.ok(fatal);
|
|
106
|
+
assert.equal(fatal.recoverable, false);
|
|
107
|
+
assert.match(fatal.message, /POST \/api\/auth\/sign-up\/email failed: 404/);
|
|
108
|
+
});
|
|
109
|
+
});
|