diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index 8d9878af3..7d3409c37 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -126,6 +126,8 @@ export class SNSessionManager extends AbstractService implements S } private setSession(session: Session, persist = true): void { + this.httpService.setAuthorizationToken(session.authorizationValue) + this.apiService.setSession(session, persist) } @@ -621,8 +623,6 @@ export class SNSessionManager extends AbstractService implements S this.httpService.setHost(host) - this.httpService.setAuthorizationToken(session.authorizationValue) - await this.setSession(session) this.webSocketsService.startWebSocketConnection(session.authorizationValue) diff --git a/packages/web/package.json b/packages/web/package.json index 82bf6fa7e..dbad9f7ae 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,6 +13,7 @@ "clean": "rm -fr dist && rm -rf src/components", "format": "prettier --write src/javascripts", "lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts", + "lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts --fix", "start": "webpack-dev-server --config web.webpack.dev.js", "start-secure": "yarn start --server-type https", "test": "jest --config jest.config.js --coverage", diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx index efdfbf2b7..30391f525 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx @@ -8,6 +8,7 @@ import Subscription from './Subscription/Subscription' import SignOutWrapper from './SignOutView' import FilesSection from './Files' import PreferencesPane from '../../PreferencesComponents/PreferencesPane' +import SubscriptionSharing from './SubscriptionSharing/SubscriptionSharing' type Props = { application: WebApplication @@ -25,6 +26,7 @@ const AccountPreferences = ({ application, viewControllerManager }: Props) => ( )} + {application.hasAccount() && viewControllerManager.featuresController.hasFiles && ( )} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/InvitationsList.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/InvitationsList.tsx new file mode 100644 index 000000000..60e18d7f5 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/InvitationsList.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { observer } from 'mobx-react-lite' +import { InvitationStatus, Uuid } from '@standardnotes/snjs' + +import { SubtitleLight, Text } from '@/Components/Preferences/PreferencesComponents/Content' +import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController' +import Button from '@/Components/Button/Button' +import { WebApplication } from '@/Application/Application' +import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' + +type Props = { + subscriptionState: SubscriptionController + application: WebApplication +} + +const InvitationsList = ({ subscriptionState, application }: Props) => { + const [lockContinue, setLockContinue] = useState(false) + + const { usedInvitationsCount, subscriptionInvitations } = subscriptionState + + const activeSubscriptions = subscriptionInvitations?.filter((invitation) => + [InvitationStatus.Sent, InvitationStatus.Accepted].includes(invitation.status), + ) + const inActiveSubscriptions = subscriptionInvitations?.filter((invitation) => + [InvitationStatus.Declined, InvitationStatus.Canceled].includes(invitation.status), + ) + + const handleCancel = async (invitationUuid: Uuid) => { + if (lockContinue) { + application.alertService.alert('Cancelation already in progress.').catch(console.error) + + return + } + + setLockContinue(true) + + const success = await subscriptionState.cancelSubscriptionInvitation(invitationUuid) + + setLockContinue(false) + + if (!success) { + application.alertService + .alert('Could not cancel invitation. Please try again or contact support if the issue persists.') + .catch(console.error) + } + } + + if (usedInvitationsCount === 0) { + return Make your first subscription invitation below. + } + + return ( +
+ Active Invitations: + {activeSubscriptions?.map((invitation) => ( +
+ + {invitation.inviteeIdentifier} ({invitation.status}) + + {invitation.status !== InvitationStatus.Canceled && ( +
+ ))} + {!!inActiveSubscriptions?.length && ( + <> + Inactive Invitations: +
+ {inActiveSubscriptions?.map((invitation) => ( +
+ + {invitation.inviteeIdentifier} ({invitation.status}) + +
+ ))} +
+ + )} + {!subscriptionState.allInvitationsUsed && } +
+ ) +} + +export default observer(InvitationsList) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/Invite.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/Invite.tsx new file mode 100644 index 000000000..e061c19a6 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/Invite.tsx @@ -0,0 +1,128 @@ +import { FunctionComponent, useState } from 'react' + +import ModalDialog from '@/Components/Shared/ModalDialog' +import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons' +import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription' +import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel' +import Button from '@/Components/Button/Button' +import { WebApplication } from '@/Application/Application' +import { isEmailValid } from '@/Utils' +import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController' + +import InviteForm from './InviteForm' +import InviteSuccess from './InviteSuccess' + +enum SubmitButtonTitles { + Default = 'Send Invitation', + Sending = 'Sending...', + Finish = 'Finish', +} + +enum Steps { + InitialStep, + FinishStep, +} + +type Props = { + onCloseDialog: () => void + application: WebApplication + subscriptionState: SubscriptionController +} + +const Invite: FunctionComponent = ({ onCloseDialog, application, subscriptionState }) => { + const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default) + const [inviteeEmail, setInviteeEmail] = useState('') + const [isContinuing, setIsContinuing] = useState(false) + const [lockContinue, setLockContinue] = useState(false) + const [currentStep, setCurrentStep] = useState(Steps.InitialStep) + + const validateInviteeEmail = async () => { + if (!isEmailValid(inviteeEmail)) { + application.alertService + .alert('The email you entered has an invalid format. Please review your input and try again.') + .catch(console.error) + + return false + } + + return true + } + + const handleDialogClose = () => { + if (lockContinue) { + application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error) + } else { + onCloseDialog() + } + } + + const resetProgressState = () => { + setSubmitButtonTitle(SubmitButtonTitles.Default) + setIsContinuing(false) + } + + const processInvite = async () => { + setLockContinue(true) + + const success = await subscriptionState.sendSubscriptionInvitation(inviteeEmail) + + setLockContinue(false) + + return success + } + + const handleSubmit = async () => { + if (lockContinue || isContinuing) { + return + } + + if (currentStep === Steps.FinishStep) { + handleDialogClose() + + return + } + + setIsContinuing(true) + setSubmitButtonTitle(SubmitButtonTitles.Sending) + + const valid = await validateInviteeEmail() + + if (!valid) { + resetProgressState() + + return + } + + const success = await processInvite() + if (!success) { + application.alertService + .alert('We could not send the invitation. Please try again or contact support if the issue persists.') + .catch(console.error) + + resetProgressState() + + return + } + + setIsContinuing(false) + setSubmitButtonTitle(SubmitButtonTitles.Finish) + setCurrentStep(Steps.FinishStep) + } + + return ( +
+ + Invite + + {currentStep === Steps.InitialStep && } + {currentStep === Steps.FinishStep && } + + +
+ ) +} + +export default Invite diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/InviteForm.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/InviteForm.tsx new file mode 100644 index 000000000..87d49cfa4 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/InviteForm.tsx @@ -0,0 +1,28 @@ +import { Dispatch, FunctionComponent, SetStateAction } from 'react' + +import DecoratedInput from '@/Components/Input/DecoratedInput' + +type Props = { + setInviteeEmail: Dispatch> +} + +const InviteForm: FunctionComponent = ({ setInviteeEmail }) => { + return ( +
+
+ + { + setInviteeEmail(email) + }} + /> +
+
+ ) +} + +export default InviteForm diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/InviteSuccess.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/InviteSuccess.tsx new file mode 100644 index 000000000..d91d892b8 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/Invite/InviteSuccess.tsx @@ -0,0 +1,11 @@ +import { FunctionComponent } from 'react' + +const InviteSuccess: FunctionComponent = () => { + return ( +
+
Your invitation has been successfully sent.
+
+ ) +} + +export default InviteSuccess diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx new file mode 100644 index 000000000..8b5937e0d --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx @@ -0,0 +1,47 @@ +import { FunctionComponent, useState } from 'react' +import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents/Content' +import Button from '@/Components/Button/Button' +import { WebApplication } from '@/Application/Application' +import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions' + +type Props = { + application: WebApplication +} + +const NoProSubscription: FunctionComponent = ({ application }) => { + const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false) + const [purchaseFlowError, setPurchaseFlowError] = useState(undefined) + + const onPurchaseClick = async () => { + const errorMessage = 'There was an error when attempting to redirect you to the subscription page.' + setIsLoadingPurchaseFlow(true) + try { + if (!(await loadPurchaseFlowUrl(application))) { + setPurchaseFlowError(errorMessage) + } + } catch (e) { + setPurchaseFlowError(errorMessage) + } finally { + setIsLoadingPurchaseFlow(false) + } + } + + return ( + <> + + Subscription sharing is available only on the Professional plan. Please + upgrade in order to share subscription. + + {isLoadingPurchaseFlow && Redirecting you to the subscription page...} + {purchaseFlowError && {purchaseFlowError}} +
+ + {application.hasAccount() && ( +
+ + ) +} + +export default NoProSubscription diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/SharingStatusText.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/SharingStatusText.tsx new file mode 100644 index 000000000..05431c22f --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/SharingStatusText.tsx @@ -0,0 +1,18 @@ +import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController' +import { observer } from 'mobx-react-lite' +import { Text } from '@/Components/Preferences/PreferencesComponents/Content' + +type Props = { subscriptionState: SubscriptionController } + +const SharingStatusText = ({ subscriptionState }: Props) => { + const { usedInvitationsCount, allowedInvitationsCount } = subscriptionState + + return ( + + You have have used {usedInvitationsCount} out of {allowedInvitationsCount}{' '} + subscription invitations. + + ) +} + +export default observer(SharingStatusText) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/SubscriptionSharing.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/SubscriptionSharing.tsx new file mode 100644 index 000000000..27fd7d8ad --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/SubscriptionSharing.tsx @@ -0,0 +1,63 @@ +import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { FunctionComponent, useState } from 'react' + +import { Title } from '@/Components/Preferences/PreferencesComponents/Content' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Controllers/ViewControllerManager' +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' + +import NoProSubscription from './NoProSubscription' +import InvitationsList from './InvitationsList' +import Invite from './Invite/Invite' +import Button from '@/Components/Button/Button' +import SharingStatusText from './SharingStatusText' + +type Props = { + application: WebApplication + viewControllerManager: ViewControllerManager +} + +const SubscriptionSharing: FunctionComponent = ({ application, viewControllerManager }: Props) => { + const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false) + + const subscriptionState = viewControllerManager.subscriptionController + + const isSubscriptionSharingFeatureAvailable = + application.features.getFeatureStatus(FeatureIdentifier.TwoFactorAuth) === FeatureStatus.Entitled + + return ( + + +
+
+ Subscription Sharing + {isSubscriptionSharingFeatureAvailable ? ( +
+ + + + {!subscriptionState.allInvitationsUsed && ( +
+ ) : ( + + )} +
+
+
+
+ ) +} + +export default observer(SubscriptionSharing) diff --git a/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx b/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx index 39bf0325e..dbada4974 100644 --- a/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx +++ b/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx @@ -1,29 +1,25 @@ +import { classNames } from '@/Utils/ConcatenateClassNames' import { FunctionComponent, ReactNode } from 'react' -type ChildrenProp = { - children: ReactNode -} - -export const Title: FunctionComponent = ({ children }) => ( - <> -

{children}

- -) - type Props = { className?: string -} & ChildrenProp + children: ReactNode +} -export const Subtitle: FunctionComponent = ({ children, className = '' }) => ( -

{children}

+export const Title: FunctionComponent = ({ children, className }) => ( +

{children}

) -export const SubtitleLight: FunctionComponent = ({ children, className = '' }) => ( -

{children}

+export const Subtitle: FunctionComponent = ({ children, className }) => ( +

{children}

) -export const Text: FunctionComponent = ({ children, className = '' }) => ( -

{children}

+export const SubtitleLight: FunctionComponent = ({ children, className }) => ( +

{children}

+) + +export const Text: FunctionComponent = ({ children, className }) => ( +

{children}

) const buttonClasses = diff --git a/packages/web/src/javascripts/Controllers/Subscription/SubscriptionController.ts b/packages/web/src/javascripts/Controllers/Subscription/SubscriptionController.ts index c10768d85..6fb556ec2 100644 --- a/packages/web/src/javascripts/Controllers/Subscription/SubscriptionController.ts +++ b/packages/web/src/javascripts/Controllers/Subscription/SubscriptionController.ts @@ -4,6 +4,10 @@ import { ClientDisplayableError, convertTimestampToMilliseconds, InternalEventBus, + Invitation, + InvitationStatus, + SubscriptionClientInterface, + Uuid, } from '@standardnotes/snjs' import { action, computed, makeObservable, observable } from 'mobx' import { WebApplication } from '../../Application/Application' @@ -12,28 +16,40 @@ import { AvailableSubscriptions } from './AvailableSubscriptionsType' import { Subscription } from './SubscriptionType' export class SubscriptionController extends AbstractViewController { + private readonly ALLOWED_SUBSCRIPTION_INVITATIONS = 5 + userSubscription: Subscription | undefined = undefined availableSubscriptions: AvailableSubscriptions | undefined = undefined + subscriptionInvitations: Invitation[] | undefined = undefined override deinit() { super.deinit() ;(this.userSubscription as unknown) = undefined ;(this.availableSubscriptions as unknown) = undefined + ;(this.subscriptionInvitations as unknown) = undefined destroyAllObjectProperties(this) } - constructor(application: WebApplication, eventBus: InternalEventBus) { + constructor( + application: WebApplication, + eventBus: InternalEventBus, + private subscriptionManager: SubscriptionClientInterface, + ) { super(application, eventBus) makeObservable(this, { userSubscription: observable, availableSubscriptions: observable, + subscriptionInvitations: observable, userSubscriptionName: computed, userSubscriptionExpirationDate: computed, isUserSubscriptionExpired: computed, isUserSubscriptionCanceled: computed, + usedInvitationsCount: computed, + allowedInvitationsCount: computed, + allInvitationsUsed: computed, setUserSubscription: action, setAvailableSubscriptions: action, @@ -43,6 +59,7 @@ export class SubscriptionController extends AbstractViewController { application.addEventObserver(async () => { if (application.hasAccount()) { this.getSubscriptionInfo().catch(console.error) + this.reloadSubscriptionInvitations().catch(console.error) } }, ApplicationEvent.Launched), ) @@ -50,12 +67,14 @@ export class SubscriptionController extends AbstractViewController { this.disposers.push( application.addEventObserver(async () => { this.getSubscriptionInfo().catch(console.error) + this.reloadSubscriptionInvitations().catch(console.error) }, ApplicationEvent.SignedIn), ) this.disposers.push( application.addEventObserver(async () => { this.getSubscriptionInfo().catch(console.error) + this.reloadSubscriptionInvitations().catch(console.error) }, ApplicationEvent.UserRolesChanged), ) } @@ -91,6 +110,22 @@ export class SubscriptionController extends AbstractViewController { return Boolean(this.userSubscription?.cancelled) } + get usedInvitationsCount(): number { + return ( + this.subscriptionInvitations?.filter((invitation) => + [InvitationStatus.Accepted, InvitationStatus.Sent].includes(invitation.status), + ).length ?? 0 + ) + } + + get allowedInvitationsCount(): number { + return this.ALLOWED_SUBSCRIPTION_INVITATIONS + } + + get allInvitationsUsed(): boolean { + return this.usedInvitationsCount === this.ALLOWED_SUBSCRIPTION_INVITATIONS + } + public setUserSubscription(subscription: Subscription): void { this.userSubscription = subscription } @@ -99,6 +134,26 @@ export class SubscriptionController extends AbstractViewController { this.availableSubscriptions = subscriptions } + async sendSubscriptionInvitation(inviteeEmail: string): Promise { + const success = await this.subscriptionManager.inviteToSubscription(inviteeEmail) + + if (success) { + await this.reloadSubscriptionInvitations() + } + + return success + } + + async cancelSubscriptionInvitation(invitationUuid: Uuid): Promise { + const success = await this.subscriptionManager.cancelInvitation(invitationUuid) + + if (success) { + await this.reloadSubscriptionInvitations() + } + + return success + } + private async getAvailableSubscriptions() { try { const subscriptions = await this.application.getAvailableSubscriptions() @@ -125,4 +180,8 @@ export class SubscriptionController extends AbstractViewController { await this.getSubscription() await this.getAvailableSubscriptions() } + + private async reloadSubscriptionInvitations(): Promise { + this.subscriptionInvitations = await this.subscriptionManager.listSubscriptionInvitations() + } } diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index f1541d567..d2cec0695 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -9,6 +9,7 @@ import { InternalEventBus, ItemCounterInterface, ItemCounter, + SubscriptionClientInterface, } from '@standardnotes/snjs' import { action, makeObservable, observable } from 'mobx' import { ActionsMenuController } from './ActionsMenuController' @@ -60,12 +61,15 @@ export class ViewControllerManager { private appEventObserverRemovers: (() => void)[] = [] private eventBus: InternalEventBus private itemCounter: ItemCounterInterface + private subscriptionManager: SubscriptionClientInterface constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) { this.eventBus = new InternalEventBus() this.itemCounter = new ItemCounter() + this.subscriptionManager = application.subscriptions + this.selectionController = new SelectedItemsController(application, this.eventBus) this.noteTagsController = new NoteTagsController(application, this.eventBus) @@ -102,7 +106,7 @@ export class ViewControllerManager { this.accountMenuController = new AccountMenuController(application, this.eventBus, this.itemCounter) - this.subscriptionController = new SubscriptionController(application, this.eventBus) + this.subscriptionController = new SubscriptionController(application, this.eventBus, this.subscriptionManager) this.purchaseFlowController = new PurchaseFlowController(application, this.eventBus)