Compare commits

...
Author SHA1 Message Date
StandardNotes CI 5a8752b95a chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-09-16 13:35:11 +00:00
Aman Harwara 7807fa34f3 fix: long titles on non-mobile view (#1575) 2022-09-16 18:35:43 +05:30
StandardNotes CI ddf4c97477 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-15 18:43:48 +00:00
Aman Harwara 3a15142940 fix: biometrics input on mobile webview challenge modal (#1572) 2022-09-15 23:41:03 +05:30
StandardNotes CI 79518b6a5d chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
2022-09-15 10:28:11 +00:00
Karol SójkoandAman Harwara 2d0ee10226 feat: sharing subscriptions UI (#1567)
* feat(web): add ui for subscription sharing

* fix(web): add missing triggers

* fix(snjs): setting authorization token on http service

* fix(web): add alert upon invite failure

* fix(web): display invitations list

* fix(web): canceling subscription invitations

* fix(web): fonts

* fix(web): linter issues

* fix: click event handler

* fix: styles

* feat: update styles

* feat: don't show bottom separator if all invites used

* fix(web): references to alert service

* fix(web): remove usebeforeunload

Co-authored-by: Aman Harwara <[email protected]>
2022-09-15 12:00:29 +02:00
32 changed files with 658 additions and 38 deletions
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.23.126](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.125](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.124](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.123](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/desktop
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.123",
"version": "3.23.126",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+14
View File
@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.33.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/mobile
## [3.33.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Bug Fixes
* biometrics input on mobile webview challenge modal ([#1572](https://github.com/standardnotes/app/issues/1572)) ([3a15142](https://github.com/standardnotes/app/commit/3a15142940ef9391868442e1997c3d9d5eec33be))
## [3.33.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/mobile
## [3.33.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/mobile
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.33.3",
"version": "3.33.6",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
+47
View File
@@ -288,6 +288,53 @@ export class MobileDeviceInterface implements DeviceInterface {
}
}
authenticateWithBiometrics() {
return new Promise<boolean>((resolve) => {
if (Platform.OS === 'android') {
FingerprintScanner.authenticate({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts type does not exist for deviceCredentialAllowed
deviceCredentialAllowed: true,
description: 'Biometrics are required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error) => {
FingerprintScanner.release()
if (error.name === 'DeviceLocked') {
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
resolve(false)
})
} else {
// iOS
FingerprintScanner.authenticate({
fallbackEnabled: true,
description: 'This is required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error_1) => {
FingerprintScanner.release()
if (error_1.name !== 'SystemCancel') {
if (error_1.name !== 'UserCancel') {
Alert.alert('Unsuccessful')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
}
resolve(false)
})
}
})
}
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
return Keychain.getKeys()
}
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.49](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.48](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.47](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.46](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.46",
"version": "1.3.49",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Bug Fixes
* biometrics input on mobile webview challenge modal ([#1572](https://github.com/standardnotes/app/issues/1572)) ([3a15142](https://github.com/standardnotes/app/commit/3a15142940ef9391868442e1997c3d9d5eec33be))
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.20.1",
"version": "1.20.2",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -9,4 +9,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
setAndroidScreenshotPrivacy(enable: boolean): Promise<void>
getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined>
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void>
authenticateWithBiometrics(): Promise<boolean>
}
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.129.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/snjs
# [2.129.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Features
* sharing subscriptions UI ([#1567](https://github.com/standardnotes/app/issues/1567)) ([2d0ee10](https://github.com/standardnotes/app/commit/2d0ee10226687df1d24926b9408ce270557a5b57))
## [2.128.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/snjs
@@ -126,6 +126,8 @@ export class SNSessionManager extends AbstractService<SessionEvent> 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<SessionEvent> implements S
this.httpService.setHost(host)
this.httpService.setAuthorizationToken(session.authorizationValue)
await this.setSession(session)
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.128.1",
"version": "2.129.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.2.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.2.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/ui-services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.2.3",
"version": "1.2.4",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+18
View File
@@ -3,6 +3,24 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.48.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
### Bug Fixes
* long titles on non-mobile view ([#1575](https://github.com/standardnotes/app/issues/1575)) ([7807fa3](https://github.com/standardnotes/app/commit/7807fa34f30f47ef3d7025bc4c804dbb9079c15a))
## [3.48.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Bug Fixes
* biometrics input on mobile webview challenge modal ([#1572](https://github.com/standardnotes/app/issues/1572)) ([3a15142](https://github.com/standardnotes/app/commit/3a15142940ef9391868442e1997c3d9d5eec33be))
# [3.48.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Features
* sharing subscriptions UI ([#1567](https://github.com/standardnotes/app/issues/1567)) ([2d0ee10](https://github.com/standardnotes/app/commit/2d0ee10226687df1d24926b9408ce270557a5b57))
## [3.47.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/web
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.47.3",
"version": "3.48.2",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
@@ -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",
@@ -5,6 +5,7 @@ import {
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValidation,
ChallengeValue,
removeFromArray,
} from '@standardnotes/snjs'
@@ -17,6 +18,7 @@ import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { ChallengeModalValues } from './ChallengeModalValues'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
@@ -64,9 +66,11 @@ const ChallengeModal: FunctionComponent<Props> = ({
const [isProcessing, setIsProcessing] = useState(false)
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
challenge.reason,
)
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
const submit = useCallback(() => {
@@ -106,7 +110,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
}, [application, challenge, isProcessing, isSubmitting, values])
const onValueChange = useCallback(
(value: string | number, prompt: ChallengePrompt) => {
(value: InputValue['value'], prompt: ChallengePrompt) => {
const newValues = { ...values }
newValues[prompt.id].invalid = false
newValues[prompt.id].value = value
@@ -169,6 +173,17 @@ const ChallengeModal: FunctionComponent<Props> = ({
}
}, [application, challenge, onDismiss])
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric)
const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt
const hasBiometricPromptValue = biometricPrompt && values[biometricPrompt.id].value
useEffect(() => {
const shouldAutoSubmit = hasOnlyBiometricPrompt && hasBiometricPromptValue
if (shouldAutoSubmit) {
submit()
}
}, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit])
if (!challenge.prompts) {
return null
}
@@ -201,11 +216,9 @@ const ChallengeModal: FunctionComponent<Props> = ({
)}
<ProtectedIllustration className="mb-4 h-30 w-30" />
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
{challenge.subheading && (
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
)}
<form
className="flex min-w-76 flex-col items-center"
onSubmit={(e) => {
@@ -215,6 +228,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
>
{challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt
application={application}
key={prompt.id}
prompt={prompt}
values={values}
@@ -1,25 +1,50 @@
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
import {
ChallengePrompt,
ChallengeValidation,
MobileDeviceInterface,
ProtectionSessionDurations,
} from '@standardnotes/snjs'
import { FunctionComponent, useEffect, useRef } from 'react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { ChallengeModalValues } from './ChallengeModalValues'
import Button from '../Button/Button'
import { WebApplication } from '@/Application/Application'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
prompt: ChallengePrompt
values: ChallengeModalValues
index: number
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
isInvalid: boolean
}
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
const ChallengeModalPrompt: FunctionComponent<Props> = ({
application,
prompt,
values,
index,
onValueChange,
isInvalid,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const biometricsButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (index === 0) {
const isNotFirstPrompt = index !== 0
if (isNotFirstPrompt) {
return
}
if (prompt.validation === ChallengeValidation.Biometric) {
biometricsButtonRef.current?.click()
} else {
inputRef.current?.focus()
}
}, [index])
}, [index, prompt.validation])
useEffect(() => {
if (isInvalid) {
@@ -61,6 +86,22 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index,
})}
</div>
</div>
) : prompt.validation === ChallengeValidation.Biometric ? (
<div className="min-w-76">
<Button
primary
fullWidth
onClick={async () => {
const authenticated = await (
application.deviceInterface as MobileDeviceInterface
).authenticateWithBiometrics()
onValueChange(authenticated, prompt)
}}
ref={biometricsButtonRef}
>
Tap to use biometrics
</Button>
</div>
) : prompt.secureTextEntry ? (
<DecoratedPasswordInput
ref={inputRef}
@@ -39,7 +39,7 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
<div className="flex h-8 items-center justify-between">
<div className="flex flex-grow items-center">
<MobileItemsListButton />
<div className="title overflow-auto">
<div className="title flex-grow overflow-auto">
<input
className="input text-lg"
id={ElementIds.FileTitleEditor}
@@ -916,7 +916,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
<div className="mb-2 flex flex-wrap items-start justify-between gap-2 md:mb-0 md:flex-nowrap md:gap-0 xl:items-center">
<div className={classNames(this.state.noteLocked && 'locked', 'flex flex-grow items-center')}>
<MobileItemsListButton />
<div className="title overflow-auto">
<div className="title flex-grow overflow-auto">
<input
className="input text-lg"
disabled={this.state.noteLocked}
@@ -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) => (
</>
)}
<Subscription application={application} viewControllerManager={viewControllerManager} />
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
<FilesSection application={application} />
)}
@@ -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 <Text className="mt-1 mb-3">Make your first subscription invitation below.</Text>
}
return (
<div>
<SubtitleLight className="mb-2 text-info">Active Invitations:</SubtitleLight>
{activeSubscriptions?.map((invitation) => (
<div key={invitation.uuid} className="mt-1 mb-4">
<Text>
{invitation.inviteeIdentifier} <span className="text-info">({invitation.status})</span>
</Text>
{invitation.status !== InvitationStatus.Canceled && (
<Button className="mt-2 min-w-20" label="Cancel" onClick={() => handleCancel(invitation.uuid)} />
)}
</div>
))}
{!!inActiveSubscriptions?.length && (
<>
<SubtitleLight className="mb-2 text-info">Inactive Invitations:</SubtitleLight>
<div>
{inActiveSubscriptions?.map((invitation) => (
<div key={invitation.uuid} className="mb-3 first:mt-2">
<Text className="mt-1">
{invitation.inviteeIdentifier} <span className="text-info">({invitation.status})</span>
</Text>
</div>
))}
</div>
</>
)}
{!subscriptionState.allInvitationsUsed && <HorizontalSeparator classes="my-4" />}
</div>
)
}
export default observer(InvitationsList)
@@ -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<Props> = ({ 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 (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>Invite</ModalDialogLabel>
<ModalDialogDescription className="flex flex-row items-center px-4.5">
{currentStep === Steps.InitialStep && <InviteForm setInviteeEmail={setInviteeEmail} />}
{currentStep === Steps.FinishStep && <InviteSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button className="min-w-20" primary label={submitButtonTitle} onClick={handleSubmit} />
</ModalDialogButtons>
</ModalDialog>
</div>
)
}
export default Invite
@@ -0,0 +1,28 @@
import { Dispatch, FunctionComponent, SetStateAction } from 'react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
type Props = {
setInviteeEmail: Dispatch<SetStateAction<string>>
}
const InviteForm: FunctionComponent<Props> = ({ setInviteeEmail }) => {
return (
<div className="flex w-full flex-col">
<div className="mb-3">
<label className="mb-1 block" htmlFor="invite-email-input">
Invitee Email:
</label>
<DecoratedInput
type="email"
id="invite-email-input"
onChange={(email) => {
setInviteeEmail(email)
}}
/>
</div>
</div>
)
}
export default InviteForm
@@ -0,0 +1,11 @@
import { FunctionComponent } from 'react'
const InviteSuccess: FunctionComponent = () => {
return (
<div>
<div className={'mb-2 font-bold text-info'}>Your invitation has been successfully sent.</div>
</div>
)
}
export default InviteSuccess
@@ -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<Props> = ({ application }) => {
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(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 (
<>
<Text>
Subscription sharing is available only on the <span className="font-bold">Professional</span> plan. Please
upgrade in order to share subscription.
</Text>
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
{application.hasAccount() && (
<Button className="mt-3 min-w-20" primary label="Upgrade" onClick={onPurchaseClick} />
)}
</div>
</>
)
}
export default NoProSubscription
@@ -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 (
<Text className="mt-1">
You have have used <span className="font-bold">{usedInvitationsCount}</span> out of {allowedInvitationsCount}{' '}
subscription invitations.
</Text>
)
}
export default observer(SharingStatusText)
@@ -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<Props> = ({ application, viewControllerManager }: Props) => {
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false)
const subscriptionState = viewControllerManager.subscriptionController
const isSubscriptionSharingFeatureAvailable =
application.features.getFeatureStatus(FeatureIdentifier.TwoFactorAuth) === FeatureStatus.Entitled
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex flex-grow flex-col">
<Title className="mb-2">Subscription Sharing</Title>
{isSubscriptionSharingFeatureAvailable ? (
<div>
<SharingStatusText subscriptionState={subscriptionState} />
<HorizontalSeparator classes="my-4" />
<InvitationsList subscriptionState={subscriptionState} application={application} />
{!subscriptionState.allInvitationsUsed && (
<Button className="min-w-20" label="Invite" onClick={() => setIsInviteDialogOpen(true)} />
)}
{isInviteDialogOpen && (
<Invite
onCloseDialog={() => setIsInviteDialogOpen(false)}
application={application}
subscriptionState={subscriptionState}
/>
)}
</div>
) : (
<NoProSubscription application={application} />
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(SubscriptionSharing)
@@ -1,29 +1,25 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { FunctionComponent, ReactNode } from 'react'
type ChildrenProp = {
children: ReactNode
}
export const Title: FunctionComponent<ChildrenProp> = ({ children }) => (
<>
<h2 className="m-0 mb-1 text-lg font-bold text-info md:text-base">{children}</h2>
</>
)
type Props = {
className?: string
} & ChildrenProp
children: ReactNode
}
export const Subtitle: FunctionComponent<Props> = ({ children, className = '' }) => (
<h4 className={`m-0 mb-1 text-sm font-medium ${className}`}>{children}</h4>
export const Title: FunctionComponent<Props> = ({ children, className }) => (
<h2 className={classNames('m-0 mb-1 text-lg font-bold text-info md:text-base', className)}>{children}</h2>
)
export const SubtitleLight: FunctionComponent<Props> = ({ children, className = '' }) => (
<h4 className={`m-0 mb-1 text-sm font-normal ${className}`}>{children}</h4>
export const Subtitle: FunctionComponent<Props> = ({ children, className }) => (
<h4 className={classNames('m-0 mb-1 text-sm font-medium', className)}>{children}</h4>
)
export const Text: FunctionComponent<Props> = ({ children, className = '' }) => (
<p className={`${className} text-sm md:text-xs`}>{children}</p>
export const SubtitleLight: FunctionComponent<Props> = ({ children, className }) => (
<h4 className={classNames('m-0 mb-1 text-sm font-normal', className)}>{children}</h4>
)
export const Text: FunctionComponent<Props> = ({ children, className }) => (
<p className={classNames('text-sm md:text-xs', className)}>{children}</p>
)
const buttonClasses =
@@ -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<boolean> {
const success = await this.subscriptionManager.inviteToSubscription(inviteeEmail)
if (success) {
await this.reloadSubscriptionInvitations()
}
return success
}
async cancelSubscriptionInvitation(invitationUuid: Uuid): Promise<boolean> {
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<void> {
this.subscriptionInvitations = await this.subscriptionManager.listSubscriptionInvitations()
}
}
@@ -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)