feat: authorize notes for listed (#1823)

This commit is contained in:
Mo
2022-10-18 10:49:30 -05:00
committed by GitHub
parent e4de9c1be7
commit 9954bdc29f
14 changed files with 104 additions and 46 deletions

View File

@@ -16,6 +16,7 @@ export class SNNote extends DecryptedItem<NoteContent> implements NoteContentSpe
public readonly preview_html: string public readonly preview_html: string
public readonly spellcheck?: boolean public readonly spellcheck?: boolean
public readonly noteType?: NoteType public readonly noteType?: NoteType
public readonly authorizedForListed: boolean
/** The package_info.identifier of the editor (not its uuid), such as org.standardnotes.advanced-markdown */ /** The package_info.identifier of the editor (not its uuid), such as org.standardnotes.advanced-markdown */
public readonly editorIdentifier?: FeatureIdentifier | string public readonly editorIdentifier?: FeatureIdentifier | string
@@ -31,6 +32,7 @@ export class SNNote extends DecryptedItem<NoteContent> implements NoteContentSpe
this.spellcheck = this.payload.content.spellcheck this.spellcheck = this.payload.content.spellcheck
this.noteType = this.payload.content.noteType this.noteType = this.payload.content.noteType
this.editorIdentifier = this.payload.content.editorIdentifier this.editorIdentifier = this.payload.content.editorIdentifier
this.authorizedForListed = this.payload.content.authorizedForListed || false
if (!this.noteType) { if (!this.noteType) {
const prefersPlain = this.getAppDomainValueWithDefault(AppDataField.LegacyPrefersPlainEditor, false) const prefersPlain = this.getAppDomainValueWithDefault(AppDataField.LegacyPrefersPlainEditor, false)

View File

@@ -10,6 +10,7 @@ export interface NoteContentSpecialized {
spellcheck?: boolean spellcheck?: boolean
noteType?: NoteType noteType?: NoteType
editorIdentifier?: FeatureIdentifier | string editorIdentifier?: FeatureIdentifier | string
authorizedForListed?: boolean
} }
export type NoteContent = NoteContentSpecialized & ItemContent export type NoteContent = NoteContentSpecialized & ItemContent

View File

@@ -39,6 +39,10 @@ export class NoteMutator extends DecryptedItemMutator<NoteContent> {
this.mutableContent.editorIdentifier = identifier this.mutableContent.editorIdentifier = identifier
} }
set authorizedForListed(authorizedForListed: boolean) {
this.mutableContent.authorizedForListed = authorizedForListed
}
toggleSpellcheck(): void { toggleSpellcheck(): void {
if (this.mutableContent.spellcheck == undefined) { if (this.mutableContent.spellcheck == undefined) {
this.mutableContent.spellcheck = false this.mutableContent.spellcheck = false

View File

@@ -24,4 +24,5 @@ export enum ChallengeReason {
UnprotectFile, UnprotectFile,
UnprotectNote, UnprotectNote,
DeleteAccount, DeleteAccount,
AuthorizeNoteForListed,
} }

View File

@@ -68,7 +68,7 @@ import { ClientDisplayableError } from '@standardnotes/responses'
import { SnjsVersion } from './../Version' import { SnjsVersion } from './../Version'
import { SNLog } from '../Log' import { SNLog } from '../Log'
import { Challenge, ChallengeResponse } from '../Services' import { Challenge, ChallengeResponse, ListedClientInterface } from '../Services'
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
import { ApplicationOptionsDefaults } from './Options/Defaults' import { ApplicationOptionsDefaults } from './Options/Defaults'
@@ -86,9 +86,7 @@ type ApplicationObserver = {
type ObserverRemover = () => void type ObserverRemover = () => void
export class SNApplication export class SNApplication implements ApplicationInterface, AppGroupManagedApplication {
implements ApplicationInterface, AppGroupManagedApplication, InternalServices.ListedClientInterface
{
onDeinit!: ExternalServices.DeinitCallback onDeinit!: ExternalServices.DeinitCallback
/** /**
@@ -273,6 +271,10 @@ export class SNApplication
return this.componentManagerService return this.componentManagerService
} }
public get listed(): ListedClientInterface {
return this.listedService
}
public computePrivateUsername(username: string): Promise<string | undefined> { public computePrivateUsername(username: string): Promise<string | undefined> {
return ComputePrivateUsername(this.options.crypto, username) return ComputePrivateUsername(this.options.crypto, username)
} }
@@ -682,25 +684,6 @@ export class SNApplication
return this.protectionService.authorizeSearchingProtectedNotesText() return this.protectionService.authorizeSearchingProtectedNotesText()
} }
public canRegisterNewListedAccount(): boolean {
return this.listedService.canRegisterNewListedAccount()
}
public async requestNewListedAccount(): Promise<Responses.ListedAccount | undefined> {
return this.listedService.requestNewListedAccount()
}
public async getListedAccounts(): Promise<Responses.ListedAccount[]> {
return this.listedService.getListedAccounts()
}
public getListedAccountInfo(
account: Responses.ListedAccount,
inContextOfItem?: UuidString,
): Promise<Responses.ListedAccountInfo | undefined> {
return this.listedService.getListedAccountInfo(account, inContextOfItem)
}
public async createEncryptedBackupFileForAutomatedDesktopBackups(): Promise<BackupFile | undefined> { public async createEncryptedBackupFileForAutomatedDesktopBackups(): Promise<BackupFile | undefined> {
return this.protocolService.createEncryptedBackupFile() return this.protocolService.createEncryptedBackupFile()
} }
@@ -1096,11 +1079,11 @@ export class SNApplication
this.createComponentManager() this.createComponentManager()
this.createMigrationService() this.createMigrationService()
this.createMfaService() this.createMfaService()
this.createListedService()
this.createActionsManager()
this.createFileService() this.createFileService()
this.createIntegrityService() this.createIntegrityService()
this.createMutatorService() this.createMutatorService()
this.createListedService()
this.createActionsManager()
this.createStatusService() this.createStatusService()
if (isDesktopDevice(this.deviceInterface)) { if (isDesktopDevice(this.deviceInterface)) {
@@ -1175,6 +1158,8 @@ export class SNApplication
this.itemManager, this.itemManager,
this.settingsService, this.settingsService,
this.deprecatedHttpService, this.deprecatedHttpService,
this.protectionService,
this.mutator,
this.internalEventBus, this.internalEventBus,
) )
this.services.push(this.listedService) this.services.push(this.listedService)

View File

@@ -167,6 +167,7 @@ export const ChallengeStrings = {
SelectProtectedNote: 'Authentication is required to select a protected note', SelectProtectedNote: 'Authentication is required to select a protected note',
DisableMfa: 'Authentication is required to disable two-factor authentication', DisableMfa: 'Authentication is required to disable two-factor authentication',
DeleteAccount: 'Authentication is required to delete your account', DeleteAccount: 'Authentication is required to delete your account',
ListedAuthorization: 'Authentication is required to approve this note for Listed',
} }
export const ErrorAlertStrings = { export const ErrorAlertStrings = {

View File

@@ -79,6 +79,8 @@ export class Challenge implements ChallengeInterface {
return ChallengeStrings.DisableMfa return ChallengeStrings.DisableMfa
case ChallengeReason.DeleteAccount: case ChallengeReason.DeleteAccount:
return ChallengeStrings.DeleteAccount return ChallengeStrings.DeleteAccount
case ChallengeReason.AuthorizeNoteForListed:
return ChallengeStrings.ListedAuthorization
case ChallengeReason.Custom: case ChallengeReason.Custom:
return '' return ''
default: default:

View File

@@ -1,3 +1,4 @@
import { SNNote } from '@standardnotes/models'
import { Uuid } from '@standardnotes/common' import { Uuid } from '@standardnotes/common'
import { ListedAccount, ListedAccountInfo } from '@standardnotes/responses' import { ListedAccount, ListedAccountInfo } from '@standardnotes/responses'
@@ -6,4 +7,6 @@ export interface ListedClientInterface {
requestNewListedAccount: () => Promise<ListedAccount | undefined> requestNewListedAccount: () => Promise<ListedAccount | undefined>
getListedAccounts(): Promise<ListedAccount[]> getListedAccounts(): Promise<ListedAccount[]>
getListedAccountInfo(account: ListedAccount, inContextOfItem?: Uuid): Promise<ListedAccountInfo | undefined> getListedAccountInfo(account: ListedAccount, inContextOfItem?: Uuid): Promise<ListedAccountInfo | undefined>
isNoteAuthorizedForListed(note: SNNote): boolean
authorizeNoteForListed(note: SNNote): Promise<boolean>
} }

View File

@@ -8,8 +8,9 @@ import { SNSettingsService } from '../Settings/SNSettingsService'
import { ListedClientInterface } from './ListedClientInterface' import { ListedClientInterface } from './ListedClientInterface'
import { SNApiService } from '../Api/ApiService' import { SNApiService } from '../Api/ApiService'
import { ListedAccount, ListedAccountInfo, ListedAccountInfoResponse } from '@standardnotes/responses' import { ListedAccount, ListedAccountInfo, ListedAccountInfoResponse } from '@standardnotes/responses'
import { SNActionsExtension } from '@standardnotes/models' import { NoteMutator, SNActionsExtension, SNNote } from '@standardnotes/models'
import { AbstractService, InternalEventBusInterface } from '@standardnotes/services' import { AbstractService, InternalEventBusInterface, MutatorClientInterface } from '@standardnotes/services'
import { SNProtectionService } from '../Protection'
export class ListedService extends AbstractService implements ListedClientInterface { export class ListedService extends AbstractService implements ListedClientInterface {
constructor( constructor(
@@ -17,6 +18,8 @@ export class ListedService extends AbstractService implements ListedClientInterf
private itemManager: ItemManager, private itemManager: ItemManager,
private settingsService: SNSettingsService, private settingsService: SNSettingsService,
private httpSerivce: SNHttpService, private httpSerivce: SNHttpService,
private protectionService: SNProtectionService,
private mutatorService: MutatorClientInterface,
protected override internalEventBus: InternalEventBusInterface, protected override internalEventBus: InternalEventBusInterface,
) { ) {
super(internalEventBus) super(internalEventBus)
@@ -27,6 +30,8 @@ export class ListedService extends AbstractService implements ListedClientInterf
;(this.settingsService as unknown) = undefined ;(this.settingsService as unknown) = undefined
;(this.apiService as unknown) = undefined ;(this.apiService as unknown) = undefined
;(this.httpSerivce as unknown) = undefined ;(this.httpSerivce as unknown) = undefined
;(this.protectionService as unknown) = undefined
;(this.mutatorService as unknown) = undefined
super.deinit() super.deinit()
} }
@@ -34,6 +39,23 @@ export class ListedService extends AbstractService implements ListedClientInterf
return this.apiService.user != undefined return this.apiService.user != undefined
} }
public isNoteAuthorizedForListed(note: SNNote): boolean {
return note.authorizedForListed
}
public async authorizeNoteForListed(note: SNNote): Promise<boolean> {
const result = await this.protectionService.authorizeListedPublishing()
if (result === false) {
return false
}
await this.mutatorService.changeAndSaveItem<NoteMutator>(note, (mutator) => {
mutator.authorizedForListed = true
})
return true
}
/** /**
* Account creation is asyncronous on the backend due to message-based nature of architecture. * 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. * 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) { if (inContextOfItem) {
url += `&item_uuid=${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 return undefined
} }

View File

@@ -219,13 +219,18 @@ export class SNProtectionService extends AbstractService<ProtectionEvent> implem
return this.authorizeAction(ChallengeReason.RevokeSession) return this.authorizeAction(ChallengeReason.RevokeSession)
} }
async authorizeListedPublishing(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.AuthorizeNoteForListed, { forcePrompt: true })
}
async authorizeAction( async authorizeAction(
reason: ChallengeReason, reason: ChallengeReason,
{ fallBackToAccountPassword = true, requireAccountPassword = false } = {}, { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {},
): Promise<boolean> { ): Promise<boolean> {
return this.validateOrRenewSession(reason, { return this.validateOrRenewSession(reason, {
requireAccountPassword, requireAccountPassword,
fallBackToAccountPassword, fallBackToAccountPassword,
forcePrompt,
}) })
} }
@@ -295,9 +300,9 @@ export class SNProtectionService extends AbstractService<ProtectionEvent> implem
private async validateOrRenewSession( private async validateOrRenewSession(
reason: ChallengeReason, reason: ChallengeReason,
{ fallBackToAccountPassword = true, requireAccountPassword = false } = {}, { fallBackToAccountPassword = true, requireAccountPassword = false, forcePrompt = false } = {},
): Promise<boolean> { ): Promise<boolean> {
if (this.getSessionExpiryDate() > new Date()) { if (this.getSessionExpiryDate() > new Date() && !forcePrompt) {
return true return true
} }

View File

@@ -14,10 +14,27 @@ type ListedActionsMenuProps = {
const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => { const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => {
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([]) const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([])
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true) 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( const reloadMenuGroup = useCallback(
async (group: ListedMenuGroup) => { 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) { if (!updatedAccountInfo) {
return return
@@ -39,7 +56,7 @@ const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => {
setMenuGroups(updatedGroups) setMenuGroups(updatedGroups)
}, },
[application, menuGroups, note], [application, menuGroups, note, isAuthorized],
) )
useEffect(() => { useEffect(() => {
@@ -49,19 +66,21 @@ const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => {
return return
} }
if (!isAuthorized) {
return
}
try { try {
const listedAccountEntries = await application.getListedAccounts() const listedAccountEntries = await application.listed.getListedAccounts()
if (!listedAccountEntries.length) { if (!listedAccountEntries.length) {
throw new Error('No Listed accounts found') throw new Error('No Listed accounts found')
} }
const menuGroups: ListedMenuGroup[] = [] const menuGroups: ListedMenuGroup[] = []
await Promise.all( await Promise.all(
listedAccountEntries.map(async (account) => { listedAccountEntries.map(async (account) => {
const accountInfo = await application.getListedAccountInfo(account, note.uuid) const accountInfo = await application.listed.getListedAccountInfo(account, note.uuid)
if (accountInfo) { if (accountInfo) {
menuGroups.push({ menuGroups.push({
name: accountInfo.display_name, name: accountInfo.display_name,
@@ -91,7 +110,11 @@ const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => {
} }
void fetchListedAccounts() void fetchListedAccounts()
}, [application, note.uuid]) }, [application, note.uuid, isAuthorized])
if (!isAuthorized) {
return null
}
return ( return (
<> <>

View File

@@ -17,9 +17,15 @@ const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) =>
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const toggleMenu = useCallback(() => { const toggleMenu = useCallback(async () => {
setIsOpen((isOpen) => !isOpen) if (!application.listed.isNoteAuthorizedForListed(note)) {
}, []) await application.listed.authorizeNoteForListed(note)
}
if (application.listed.isNoteAuthorizedForListed(note)) {
setIsOpen((isOpen) => !isOpen)
}
}, [application, note])
return ( return (
<div ref={menuContainerRef}> <div ref={menuContainerRef}>

View File

@@ -19,7 +19,7 @@ const Listed = ({ application }: Props) => {
const [requestingAccount, setRequestingAccount] = useState<boolean>() const [requestingAccount, setRequestingAccount] = useState<boolean>()
const reloadAccounts = useCallback(async () => { const reloadAccounts = useCallback(async () => {
setAccounts(await application.getListedAccounts()) setAccounts(await application.listed.getListedAccounts())
}, [application]) }, [application])
useEffect(() => { useEffect(() => {
@@ -30,7 +30,7 @@ const Listed = ({ application }: Props) => {
setRequestingAccount(true) setRequestingAccount(true)
const requestAccount = async () => { const requestAccount = async () => {
const account = await application.requestNewListedAccount() const account = await application.listed.requestNewListedAccount()
if (account) { if (account) {
const openSettings = await application.alertService.confirm( const openSettings = await application.alertService.confirm(
'Your new Listed blog has been successfully created!' + 'Your new Listed blog has been successfully created!' +
@@ -43,7 +43,7 @@ const Listed = ({ application }: Props) => {
) )
reloadAccounts().catch(console.error) reloadAccounts().catch(console.error)
if (openSettings) { if (openSettings) {
const info = await application.getListedAccountInfo(account) const info = await application.listed.getListedAccountInfo(account)
if (info) { if (info) {
application.deviceInterface.openUrl(info?.settings_url) application.deviceInterface.openUrl(info?.settings_url)
} }

View File

@@ -18,7 +18,7 @@ const ListedAccountItem: FunctionComponent<Props> = ({ account, showSeparator, a
useEffect(() => { useEffect(() => {
const loadAccount = async () => { const loadAccount = async () => {
setIsLoading(true) setIsLoading(true)
const info = await application.getListedAccountInfo(account) const info = await application.listed.getListedAccountInfo(account)
setAccountInfo(info) setAccountInfo(info)
setIsLoading(false) setIsLoading(false)
} }