diff --git a/packages/models/src/Domain/Syncable/Note/Note.ts b/packages/models/src/Domain/Syncable/Note/Note.ts index f04050c28..c183894b2 100644 --- a/packages/models/src/Domain/Syncable/Note/Note.ts +++ b/packages/models/src/Domain/Syncable/Note/Note.ts @@ -16,6 +16,7 @@ export class SNNote extends DecryptedItem implements NoteContentSpe public readonly preview_html: string public readonly spellcheck?: boolean public readonly noteType?: NoteType + public readonly authorizedForListed: boolean /** The package_info.identifier of the editor (not its uuid), such as org.standardnotes.advanced-markdown */ public readonly editorIdentifier?: FeatureIdentifier | string @@ -31,6 +32,7 @@ export class SNNote extends DecryptedItem implements NoteContentSpe this.spellcheck = this.payload.content.spellcheck this.noteType = this.payload.content.noteType this.editorIdentifier = this.payload.content.editorIdentifier + this.authorizedForListed = this.payload.content.authorizedForListed || false if (!this.noteType) { const prefersPlain = this.getAppDomainValueWithDefault(AppDataField.LegacyPrefersPlainEditor, false) diff --git a/packages/models/src/Domain/Syncable/Note/NoteContent.ts b/packages/models/src/Domain/Syncable/Note/NoteContent.ts index 146a27c2b..26a957697 100644 --- a/packages/models/src/Domain/Syncable/Note/NoteContent.ts +++ b/packages/models/src/Domain/Syncable/Note/NoteContent.ts @@ -10,6 +10,7 @@ export interface NoteContentSpecialized { spellcheck?: boolean noteType?: NoteType editorIdentifier?: FeatureIdentifier | string + authorizedForListed?: boolean } export type NoteContent = NoteContentSpecialized & ItemContent diff --git a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts index d618b8427..9e4d7bd0b 100644 --- a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts +++ b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts @@ -39,6 +39,10 @@ export class NoteMutator extends DecryptedItemMutator { this.mutableContent.editorIdentifier = identifier } + set authorizedForListed(authorizedForListed: boolean) { + this.mutableContent.authorizedForListed = authorizedForListed + } + toggleSpellcheck(): void { if (this.mutableContent.spellcheck == undefined) { this.mutableContent.spellcheck = false diff --git a/packages/services/src/Domain/Challenge/Types/ChallengeReason.ts b/packages/services/src/Domain/Challenge/Types/ChallengeReason.ts index 2db623e51..d0d4a6e6e 100644 --- a/packages/services/src/Domain/Challenge/Types/ChallengeReason.ts +++ b/packages/services/src/Domain/Challenge/Types/ChallengeReason.ts @@ -24,4 +24,5 @@ export enum ChallengeReason { UnprotectFile, UnprotectNote, DeleteAccount, + AuthorizeNoteForListed, } diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index e530f788e..aca04f2c1 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -68,7 +68,7 @@ import { ClientDisplayableError } from '@standardnotes/responses' import { SnjsVersion } from './../Version' import { SNLog } from '../Log' -import { Challenge, ChallengeResponse } from '../Services' +import { Challenge, ChallengeResponse, ListedClientInterface } from '../Services' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationOptionsDefaults } from './Options/Defaults' @@ -86,9 +86,7 @@ type ApplicationObserver = { type ObserverRemover = () => void -export class SNApplication - implements ApplicationInterface, AppGroupManagedApplication, InternalServices.ListedClientInterface -{ +export class SNApplication implements ApplicationInterface, AppGroupManagedApplication { onDeinit!: ExternalServices.DeinitCallback /** @@ -273,6 +271,10 @@ export class SNApplication return this.componentManagerService } + public get listed(): ListedClientInterface { + return this.listedService + } + public computePrivateUsername(username: string): Promise { return ComputePrivateUsername(this.options.crypto, username) } @@ -682,25 +684,6 @@ export class SNApplication return this.protectionService.authorizeSearchingProtectedNotesText() } - public canRegisterNewListedAccount(): boolean { - return this.listedService.canRegisterNewListedAccount() - } - - public async requestNewListedAccount(): Promise { - return this.listedService.requestNewListedAccount() - } - - public async getListedAccounts(): Promise { - return this.listedService.getListedAccounts() - } - - public getListedAccountInfo( - account: Responses.ListedAccount, - inContextOfItem?: UuidString, - ): Promise { - return this.listedService.getListedAccountInfo(account, inContextOfItem) - } - public async createEncryptedBackupFileForAutomatedDesktopBackups(): Promise { return this.protocolService.createEncryptedBackupFile() } @@ -1096,11 +1079,11 @@ export class SNApplication this.createComponentManager() this.createMigrationService() this.createMfaService() - this.createListedService() - this.createActionsManager() this.createFileService() this.createIntegrityService() this.createMutatorService() + this.createListedService() + this.createActionsManager() this.createStatusService() if (isDesktopDevice(this.deviceInterface)) { @@ -1175,6 +1158,8 @@ export class SNApplication this.itemManager, this.settingsService, this.deprecatedHttpService, + this.protectionService, + this.mutator, this.internalEventBus, ) this.services.push(this.listedService) diff --git a/packages/snjs/lib/Services/Api/Messages.ts b/packages/snjs/lib/Services/Api/Messages.ts index 29a5aa1e7..e7ba268d3 100644 --- a/packages/snjs/lib/Services/Api/Messages.ts +++ b/packages/snjs/lib/Services/Api/Messages.ts @@ -167,6 +167,7 @@ export const ChallengeStrings = { SelectProtectedNote: 'Authentication is required to select a protected note', DisableMfa: 'Authentication is required to disable two-factor authentication', DeleteAccount: 'Authentication is required to delete your account', + ListedAuthorization: 'Authentication is required to approve this note for Listed', } export const ErrorAlertStrings = { diff --git a/packages/snjs/lib/Services/Challenge/Challenge.ts b/packages/snjs/lib/Services/Challenge/Challenge.ts index c0ff9bf7d..1fad6dafb 100644 --- a/packages/snjs/lib/Services/Challenge/Challenge.ts +++ b/packages/snjs/lib/Services/Challenge/Challenge.ts @@ -79,6 +79,8 @@ export class Challenge implements ChallengeInterface { return ChallengeStrings.DisableMfa case ChallengeReason.DeleteAccount: return ChallengeStrings.DeleteAccount + case ChallengeReason.AuthorizeNoteForListed: + return ChallengeStrings.ListedAuthorization case ChallengeReason.Custom: return '' default: diff --git a/packages/snjs/lib/Services/Listed/ListedClientInterface.ts b/packages/snjs/lib/Services/Listed/ListedClientInterface.ts index bd04e3d77..55fcd0204 100644 --- a/packages/snjs/lib/Services/Listed/ListedClientInterface.ts +++ b/packages/snjs/lib/Services/Listed/ListedClientInterface.ts @@ -1,3 +1,4 @@ +import { SNNote } from '@standardnotes/models' import { Uuid } from '@standardnotes/common' import { ListedAccount, ListedAccountInfo } from '@standardnotes/responses' @@ -6,4 +7,6 @@ export interface ListedClientInterface { requestNewListedAccount: () => Promise getListedAccounts(): Promise getListedAccountInfo(account: ListedAccount, inContextOfItem?: Uuid): Promise + isNoteAuthorizedForListed(note: SNNote): boolean + authorizeNoteForListed(note: SNNote): Promise } diff --git a/packages/snjs/lib/Services/Listed/ListedService.ts b/packages/snjs/lib/Services/Listed/ListedService.ts index 8a9b574d7..8ff2975e6 100644 --- a/packages/snjs/lib/Services/Listed/ListedService.ts +++ b/packages/snjs/lib/Services/Listed/ListedService.ts @@ -8,8 +8,9 @@ import { SNSettingsService } from '../Settings/SNSettingsService' import { ListedClientInterface } from './ListedClientInterface' import { SNApiService } from '../Api/ApiService' import { ListedAccount, ListedAccountInfo, ListedAccountInfoResponse } from '@standardnotes/responses' -import { SNActionsExtension } from '@standardnotes/models' -import { AbstractService, InternalEventBusInterface } from '@standardnotes/services' +import { NoteMutator, SNActionsExtension, SNNote } from '@standardnotes/models' +import { AbstractService, InternalEventBusInterface, MutatorClientInterface } from '@standardnotes/services' +import { SNProtectionService } from '../Protection' export class ListedService extends AbstractService implements ListedClientInterface { constructor( @@ -17,6 +18,8 @@ export class ListedService extends AbstractService implements ListedClientInterf private itemManager: ItemManager, private settingsService: SNSettingsService, private httpSerivce: SNHttpService, + private protectionService: SNProtectionService, + private mutatorService: MutatorClientInterface, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -27,6 +30,8 @@ export class ListedService extends AbstractService implements ListedClientInterf ;(this.settingsService as unknown) = undefined ;(this.apiService as unknown) = undefined ;(this.httpSerivce as unknown) = undefined + ;(this.protectionService as unknown) = undefined + ;(this.mutatorService as unknown) = undefined super.deinit() } @@ -34,6 +39,23 @@ export class ListedService extends AbstractService implements ListedClientInterf return this.apiService.user != undefined } + public isNoteAuthorizedForListed(note: SNNote): boolean { + return note.authorizedForListed + } + + public async authorizeNoteForListed(note: SNNote): Promise { + const result = await this.protectionService.authorizeListedPublishing() + if (result === false) { + return false + } + + await this.mutatorService.changeAndSaveItem(note, (mutator) => { + mutator.authorizedForListed = true + }) + + return true + } + /** * Account creation is asyncronous on the backend due to message-based nature of architecture. * In order to get the newly created account, we poll the server to check for new accounts. @@ -73,8 +95,11 @@ export class ListedService extends AbstractService implements ListedClientInterf if (inContextOfItem) { url += `&item_uuid=${inContextOfItem}` } - const response = (await this.httpSerivce.getAbsolute(url)) as ListedAccountInfoResponse - if (response.error || !response.data || isString(response.data)) { + + const response = (await this.httpSerivce.getAbsolute(url).catch((error) => { + console.error(error) + })) as ListedAccountInfoResponse + if (!response || response.error || !response.data || isString(response.data)) { return undefined } diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.ts b/packages/snjs/lib/Services/Protection/ProtectionService.ts index 666ab7f6d..1ef9592aa 100644 --- a/packages/snjs/lib/Services/Protection/ProtectionService.ts +++ b/packages/snjs/lib/Services/Protection/ProtectionService.ts @@ -219,13 +219,18 @@ export class SNProtectionService extends AbstractService implem return this.authorizeAction(ChallengeReason.RevokeSession) } + async authorizeListedPublishing(): Promise { + return this.authorizeAction(ChallengeReason.AuthorizeNoteForListed, { forcePrompt: true }) + } + async authorizeAction( reason: ChallengeReason, - { fallBackToAccountPassword = true, requireAccountPassword = false } = {}, + { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {}, ): Promise { return this.validateOrRenewSession(reason, { requireAccountPassword, fallBackToAccountPassword, + forcePrompt, }) } @@ -295,9 +300,9 @@ export class SNProtectionService extends AbstractService implem private async validateOrRenewSession( reason: ChallengeReason, - { fallBackToAccountPassword = true, requireAccountPassword = false } = {}, + { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {}, ): Promise { - if (this.getSessionExpiryDate() > new Date()) { + if (this.getSessionExpiryDate() > new Date() && !forcePrompt) { return true } diff --git a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx index eb0d3a515..e49d0bf57 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx @@ -14,10 +14,27 @@ type ListedActionsMenuProps = { const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => { const [menuGroups, setMenuGroups] = useState([]) const [isFetchingAccounts, setIsFetchingAccounts] = useState(true) + const [isAuthorized, setIsAuthorized] = useState(false) + + useEffect(() => { + const authorize = async () => { + if (!application.listed.isNoteAuthorizedForListed(note)) { + await application.listed.authorizeNoteForListed(note) + } + + setIsAuthorized(application.listed.isNoteAuthorizedForListed(note)) + } + + void authorize() + }, [application, note]) const reloadMenuGroup = useCallback( async (group: ListedMenuGroup) => { - const updatedAccountInfo = await application.getListedAccountInfo(group.account, note.uuid) + if (!isAuthorized) { + return + } + + const updatedAccountInfo = await application.listed.getListedAccountInfo(group.account, note.uuid) if (!updatedAccountInfo) { return @@ -39,7 +56,7 @@ const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => { setMenuGroups(updatedGroups) }, - [application, menuGroups, note], + [application, menuGroups, note, isAuthorized], ) useEffect(() => { @@ -49,19 +66,21 @@ const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => { return } + if (!isAuthorized) { + return + } + try { - const listedAccountEntries = await application.getListedAccounts() + const listedAccountEntries = await application.listed.getListedAccounts() if (!listedAccountEntries.length) { throw new Error('No Listed accounts found') } const menuGroups: ListedMenuGroup[] = [] - await Promise.all( listedAccountEntries.map(async (account) => { - const accountInfo = await application.getListedAccountInfo(account, note.uuid) - + const accountInfo = await application.listed.getListedAccountInfo(account, note.uuid) if (accountInfo) { menuGroups.push({ name: accountInfo.display_name, @@ -91,7 +110,11 @@ const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => { } void fetchListedAccounts() - }, [application, note.uuid]) + }, [application, note.uuid, isAuthorized]) + + if (!isAuthorized) { + return null + } return ( <> diff --git a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx index 1c3daf094..b91acf0c4 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx @@ -17,9 +17,15 @@ const ListedActionsOption: FunctionComponent = ({ application, note }) => const [isOpen, setIsOpen] = useState(false) - const toggleMenu = useCallback(() => { - setIsOpen((isOpen) => !isOpen) - }, []) + const toggleMenu = useCallback(async () => { + if (!application.listed.isNoteAuthorizedForListed(note)) { + await application.listed.authorizeNoteForListed(note) + } + + if (application.listed.isNoteAuthorizedForListed(note)) { + setIsOpen((isOpen) => !isOpen) + } + }, [application, note]) return (
diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Listed/Listed.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Listed/Listed.tsx index c8a5bf500..93c31d18e 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Listed/Listed.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Listed/Listed.tsx @@ -19,7 +19,7 @@ const Listed = ({ application }: Props) => { const [requestingAccount, setRequestingAccount] = useState() const reloadAccounts = useCallback(async () => { - setAccounts(await application.getListedAccounts()) + setAccounts(await application.listed.getListedAccounts()) }, [application]) useEffect(() => { @@ -30,7 +30,7 @@ const Listed = ({ application }: Props) => { setRequestingAccount(true) const requestAccount = async () => { - const account = await application.requestNewListedAccount() + const account = await application.listed.requestNewListedAccount() if (account) { const openSettings = await application.alertService.confirm( 'Your new Listed blog has been successfully created!' + @@ -43,7 +43,7 @@ const Listed = ({ application }: Props) => { ) reloadAccounts().catch(console.error) if (openSettings) { - const info = await application.getListedAccountInfo(account) + const info = await application.listed.getListedAccountInfo(account) if (info) { application.deviceInterface.openUrl(info?.settings_url) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Listed/ListedAccountItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Listed/ListedAccountItem.tsx index 5757adb84..ef7a07586 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Listed/ListedAccountItem.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Listed/ListedAccountItem.tsx @@ -18,7 +18,7 @@ const ListedAccountItem: FunctionComponent = ({ account, showSeparator, a useEffect(() => { const loadAccount = async () => { setIsLoading(true) - const info = await application.getListedAccountInfo(account) + const info = await application.listed.getListedAccountInfo(account) setAccountInfo(info) setIsLoading(false) }