diff --git a/packages/services/src/Domain/VaultInvite/VaultInviteService.ts b/packages/services/src/Domain/VaultInvite/VaultInviteService.ts index ed032ee2c..b35c14310 100644 --- a/packages/services/src/Domain/VaultInvite/VaultInviteService.ts +++ b/packages/services/src/Domain/VaultInvite/VaultInviteService.ts @@ -191,7 +191,7 @@ export class VaultInviteService public async getInvitableContactsForSharedVault( sharedVault: SharedVaultListingInterface, ): Promise { - const users = await this.vaultUsers.getSharedVaultUsers(sharedVault) + const users = await this.vaultUsers.getSharedVaultUsersFromServer(sharedVault) if (!users) { return [] } diff --git a/packages/services/src/Domain/VaultUser/UseCase/IsReadonlyVaultMember.ts b/packages/services/src/Domain/VaultUser/UseCase/IsReadonlyVaultMember.ts new file mode 100644 index 000000000..cfd7824d6 --- /dev/null +++ b/packages/services/src/Domain/VaultUser/UseCase/IsReadonlyVaultMember.ts @@ -0,0 +1,27 @@ +import { UserServiceInterface } from '../../User/UserServiceInterface' +import { Result, SharedVaultUserPermission, SyncUseCaseInterface } from '@standardnotes/domain-core' +import { SharedVaultListingInterface } from '@standardnotes/models' +import { VaultUserCache } from '../VaultUserCache' + +export class IsReadonlyVaultMember implements SyncUseCaseInterface { + constructor( + private users: UserServiceInterface, + private cache: VaultUserCache, + ) {} + + execute(vault: SharedVaultListingInterface): Result { + if (!vault.sharing) { + return Result.ok(true) + } + + if (!vault.sharing.ownerUserUuid) { + throw new Error(`Shared vault ${vault.sharing.sharedVaultUuid} does not have an owner user uuid`) + } + + const user = this.users.sureUser + const vaultUsers = this.cache.get(vault.sharing.sharedVaultUuid) + const userPermission = vaultUsers?.find((vaultUser) => vaultUser.user_uuid === user.uuid)?.permission + + return Result.ok(userPermission === SharedVaultUserPermission.PERMISSIONS.Read) + } +} diff --git a/packages/services/src/Domain/VaultUser/UseCase/IsVaultAdmin.ts b/packages/services/src/Domain/VaultUser/UseCase/IsVaultAdmin.ts new file mode 100644 index 000000000..65d4f020c --- /dev/null +++ b/packages/services/src/Domain/VaultUser/UseCase/IsVaultAdmin.ts @@ -0,0 +1,29 @@ +import { UserServiceInterface } from '../../User/UserServiceInterface' +import { Result, SharedVaultUserPermission, SyncUseCaseInterface } from '@standardnotes/domain-core' +import { SharedVaultListingInterface } from '@standardnotes/models' +import { VaultUserCache } from '../VaultUserCache' + +export class IsVaultAdmin implements SyncUseCaseInterface { + constructor( + private users: UserServiceInterface, + private cache: VaultUserCache, + ) {} + + execute(vault: SharedVaultListingInterface): Result { + if (!vault.sharing) { + return Result.ok(true) + } + + if (!vault.sharing.ownerUserUuid) { + throw new Error(`Shared vault ${vault.sharing.sharedVaultUuid} does not have an owner user uuid`) + } + + const user = this.users.sureUser + const vaultUsers = this.cache.get(vault.sharing.sharedVaultUuid) + const userPermission = vaultUsers?.find((vaultUser) => vaultUser.user_uuid === user.uuid)?.permission + + return Result.ok( + userPermission === SharedVaultUserPermission.PERMISSIONS.Admin || vault.sharing.ownerUserUuid === user.uuid, + ) + } +} diff --git a/packages/services/src/Domain/VaultUser/VaultUserService.ts b/packages/services/src/Domain/VaultUser/VaultUserService.ts index 6e6237b90..4e9333bd9 100644 --- a/packages/services/src/Domain/VaultUser/VaultUserService.ts +++ b/packages/services/src/Domain/VaultUser/VaultUserService.ts @@ -5,21 +5,31 @@ import { InternalEventBusInterface } from './../Internal/InternalEventBusInterfa import { RemoveVaultMember } from './UseCase/RemoveSharedVaultMember' import { VaultServiceInterface } from '../Vault/VaultServiceInterface' import { GetVaultUsers } from './UseCase/GetVaultUsers' -import { SharedVaultListingInterface } from '@standardnotes/models' +import { SharedVaultListingInterface, VaultListingInterface } from '@standardnotes/models' import { VaultUserServiceInterface } from './VaultUserServiceInterface' import { ClientDisplayableError, SharedVaultUserServerHash, isClientDisplayableError } from '@standardnotes/responses' import { AbstractService } from './../Service/AbstractService' import { VaultUserServiceEvent } from './VaultUserServiceEvent' import { Result } from '@standardnotes/domain-core' import { IsVaultOwner } from './UseCase/IsVaultOwner' +import { InternalEventInterface } from '../Internal/InternalEventInterface' +import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface' +import { ApplicationEvent } from '../Event/ApplicationEvent' +import { IsReadonlyVaultMember } from './UseCase/IsReadonlyVaultMember' +import { IsVaultAdmin } from './UseCase/IsVaultAdmin' -export class VaultUserService extends AbstractService implements VaultUserServiceInterface { +export class VaultUserService + extends AbstractService + implements VaultUserServiceInterface, InternalEventHandlerInterface +{ constructor( private vaults: VaultServiceInterface, private vaultLocks: VaultLockServiceInterface, private _getVaultUsers: GetVaultUsers, private _removeVaultMember: RemoveVaultMember, private _isVaultOwner: IsVaultOwner, + private _isVaultAdmin: IsVaultAdmin, + private _isReadonlyVaultMember: IsReadonlyVaultMember, private _getVault: GetVault, private _leaveVault: LeaveVault, eventBus: InternalEventBusInterface, @@ -37,7 +47,23 @@ export class VaultUserService extends AbstractService imp ;(this._leaveVault as unknown) = undefined } - public async getSharedVaultUsers( + async handleEvent(event: InternalEventInterface): Promise { + if (event.type === ApplicationEvent.CompletedFullSync) { + this.vaults.getVaults().forEach((vault) => { + if (!vault.isSharedVaultListing()) { + return + } + this._getVaultUsers + .execute({ + sharedVaultUuid: vault.sharing.sharedVaultUuid, + readFromCache: false, + }) + .catch(console.error) + }) + } + } + + public async getSharedVaultUsersFromServer( sharedVault: SharedVaultListingInterface, ): Promise { const result = await this._getVaultUsers.execute({ @@ -55,6 +81,17 @@ export class VaultUserService extends AbstractService imp return this._isVaultOwner.execute(sharedVault).getValue() } + public isCurrentUserSharedVaultAdmin(sharedVault: SharedVaultListingInterface): boolean { + return this._isVaultAdmin.execute(sharedVault).getValue() + } + + public isCurrentUserReadonlyVaultMember(vault: VaultListingInterface): boolean { + if (!vault.isSharedVaultListing()) { + return false + } + return this._isReadonlyVaultMember.execute(vault).getValue() + } + async removeUserFromSharedVault(sharedVault: SharedVaultListingInterface, userUuid: string): Promise> { if (!this.isCurrentUserSharedVaultOwner(sharedVault)) { throw new Error('Only vault admins can remove users') @@ -101,4 +138,17 @@ export class VaultUserService extends AbstractService imp void this.notifyEvent(VaultUserServiceEvent.UsersChanged) } + + getFormattedMemberPermission(permission: string): string { + switch (permission) { + case 'admin': + return 'Admin' + case 'write': + return 'Read / Write' + case 'read': + return 'Read-only' + default: + return 'Unknown' + } + } } diff --git a/packages/services/src/Domain/VaultUser/VaultUserServiceInterface.ts b/packages/services/src/Domain/VaultUser/VaultUserServiceInterface.ts index e4f3e7281..e41f2ba65 100644 --- a/packages/services/src/Domain/VaultUser/VaultUserServiceInterface.ts +++ b/packages/services/src/Domain/VaultUser/VaultUserServiceInterface.ts @@ -1,13 +1,18 @@ import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface' -import { SharedVaultListingInterface } from '@standardnotes/models' +import { SharedVaultListingInterface, VaultListingInterface } from '@standardnotes/models' import { ClientDisplayableError, SharedVaultUserServerHash } from '@standardnotes/responses' import { VaultUserServiceEvent } from './VaultUserServiceEvent' import { Result } from '@standardnotes/domain-core' export interface VaultUserServiceInterface extends ApplicationServiceInterface { - getSharedVaultUsers(sharedVault: SharedVaultListingInterface): Promise + getSharedVaultUsersFromServer( + sharedVault: SharedVaultListingInterface, + ): Promise isCurrentUserSharedVaultOwner(sharedVault: SharedVaultListingInterface): boolean + isCurrentUserSharedVaultAdmin(sharedVault: SharedVaultListingInterface): boolean + isCurrentUserReadonlyVaultMember(vault: VaultListingInterface): boolean removeUserFromSharedVault(sharedVault: SharedVaultListingInterface, userUuid: string): Promise> leaveSharedVault(sharedVault: SharedVaultListingInterface): Promise isVaultUserOwner(user: SharedVaultUserServerHash): boolean + getFormattedMemberPermission(permission: string): string } diff --git a/packages/snjs/lib/Application/Dependencies/Dependencies.ts b/packages/snjs/lib/Application/Dependencies/Dependencies.ts index 022de1042..b54f0e9df 100644 --- a/packages/snjs/lib/Application/Dependencies/Dependencies.ts +++ b/packages/snjs/lib/Application/Dependencies/Dependencies.ts @@ -172,6 +172,8 @@ import { EncryptionOperators } from '@standardnotes/encryption' import { AsymmetricMessagePayload, AsymmetricMessageSharedVaultInvite } from '@standardnotes/models' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { AuthorizeVaultDeletion } from '@standardnotes/services/src/Domain/Vault/UseCase/AuthorizeVaultDeletion' +import { IsVaultAdmin } from '@standardnotes/services/src/Domain/VaultUser/UseCase/IsVaultAdmin' +import { IsReadonlyVaultMember } from '@standardnotes/services/src/Domain/VaultUser/UseCase/IsReadonlyVaultMember' export class Dependencies { private factory = new Map unknown>() @@ -335,6 +337,17 @@ export class Dependencies { return new IsVaultOwner(this.get(TYPES.UserService)) }) + this.factory.set(TYPES.IsVaultAdmin, () => { + return new IsVaultAdmin(this.get(TYPES.UserService), this.get(TYPES.VaultUserCache)) + }) + + this.factory.set(TYPES.IsReadonlyVaultMember, () => { + return new IsReadonlyVaultMember( + this.get(TYPES.UserService), + this.get(TYPES.VaultUserCache), + ) + }) + this.factory.set(TYPES.DecryptBackupFile, () => { return new DecryptBackupFile( this.get(TYPES.EncryptionService), @@ -844,6 +857,8 @@ export class Dependencies { this.get(TYPES.GetVaultUsers), this.get(TYPES.RemoveVaultMember), this.get(TYPES.IsVaultOwner), + this.get(TYPES.IsVaultAdmin), + this.get(TYPES.IsReadonlyVaultMember), this.get(TYPES.GetVault), this.get(TYPES.LeaveVault), this.get(TYPES.InternalEventBus), diff --git a/packages/snjs/lib/Application/Dependencies/DependencyEvents.ts b/packages/snjs/lib/Application/Dependencies/DependencyEvents.ts index 6d7781f80..c194c7711 100644 --- a/packages/snjs/lib/Application/Dependencies/DependencyEvents.ts +++ b/packages/snjs/lib/Application/Dependencies/DependencyEvents.ts @@ -43,6 +43,7 @@ export function RegisterApplicationServicesEvents(container: Dependencies, event events.addEventHandler(container.get(TYPES.VaultInviteService), ApplicationEvent.Launched) events.addEventHandler(container.get(TYPES.VaultInviteService), SyncEvent.ReceivedSharedVaultInvites) events.addEventHandler(container.get(TYPES.VaultInviteService), WebSocketsServiceEvent.UserInvitedToSharedVault) + events.addEventHandler(container.get(TYPES.VaultUserService), ApplicationEvent.CompletedFullSync) if (container.get(TYPES.FilesBackupService)) { events.addEventHandler(container.get(TYPES.FilesBackupService), ApplicationEvent.ApplicationStageChanged) diff --git a/packages/snjs/lib/Application/Dependencies/Types.ts b/packages/snjs/lib/Application/Dependencies/Types.ts index e145da9c9..5c9a485de 100644 --- a/packages/snjs/lib/Application/Dependencies/Types.ts +++ b/packages/snjs/lib/Application/Dependencies/Types.ts @@ -152,6 +152,8 @@ export const TYPES = { EncryptTypeAPayloadWithKeyLookup: Symbol.for('EncryptTypeAPayloadWithKeyLookup'), DecryptBackupFile: Symbol.for('DecryptBackupFile'), IsVaultOwner: Symbol.for('IsVaultOwner'), + IsVaultAdmin: Symbol.for('IsVaultAdmin'), + IsReadonlyVaultMember: Symbol.for('IsReadonlyVaultMember'), RemoveItemsFromMemory: Symbol.for('RemoveItemsFromMemory'), ReencryptTypeAItems: Symbol.for('ReencryptTypeAItems'), DecryptErroredPayloads: Symbol.for('DecryptErroredPayloads'), diff --git a/packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx b/packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx index e0a62f202..ff2807472 100644 --- a/packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx +++ b/packages/web/src/javascripts/Components/ComponentView/IframeFeatureView.tsx @@ -20,6 +20,7 @@ interface Props { componentViewer: ComponentViewerInterface requestReload?: (viewer: ComponentViewerInterface, force?: boolean) => void onLoad?: () => void + readonly?: boolean } /** @@ -30,7 +31,7 @@ const MaxLoadThreshold = 4000 const VisibilityChangeKey = 'visibilitychange' const MSToWaitAfterIframeLoadToAvoidFlicker = 35 -const IframeFeatureView: FunctionComponent = ({ onLoad, componentViewer, requestReload }) => { +const IframeFeatureView: FunctionComponent = ({ onLoad, componentViewer, requestReload, readonly = false }) => { const application = useApplication() const iframeRef = useRef(null) @@ -50,7 +51,7 @@ const IframeFeatureView: FunctionComponent = ({ onLoad, componentViewer, const reloadValidityStatus = useCallback(() => { setFeatureStatus(componentViewer.getFeatureStatus()) if (!componentViewer.lockReadonly) { - componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled) + componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled || readonly) } setIsComponentValid(componentViewer.shouldRender()) @@ -60,7 +61,7 @@ const IframeFeatureView: FunctionComponent = ({ onLoad, componentViewer, setError(componentViewer.getError()) setDeprecationMessage(uiFeature.deprecationMessage) - }, [componentViewer, uiFeature, featureStatus, isComponentValid, isLoading]) + }, [componentViewer, isLoading, isComponentValid, uiFeature.deprecationMessage, featureStatus, readonly]) useEffect(() => { reloadValidityStatus() diff --git a/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx b/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx index 5657eb907..46f9b0dd5 100644 --- a/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx +++ b/packages/web/src/javascripts/Components/ContentTableView/ContentTableView.tsx @@ -330,9 +330,12 @@ const ContentTableView = ({ application, items }: Props) => { setContextMenuItem(file) }, rowActions: (item) => { + const vault = application.vaults.getItemVault(item) + const isReadonly = vault?.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault) + return (
- + {!isReadonly && }
) diff --git a/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx b/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx index 1405d5502..6b97dbc53 100644 --- a/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx +++ b/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx @@ -69,6 +69,18 @@ const FileMenuOptions: FunctionComponent = ({ closeMenu() }, [closeMenu, toggleAppPane]) + const areSomeFilesInReadonlySharedVault = selectedFiles.some((file) => { + const vault = application.vaults.getItemVault(file) + return vault?.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault) + }) + const hasAdminPermissionForAllSharedFiles = selectedFiles.every((file) => { + const vault = application.vaults.getItemVault(file) + if (!vault?.isSharedVaultListing()) { + return true + } + return application.vaultUsers.isCurrentUserSharedVaultAdmin(vault) + }) + if (selectedFiles.length === 0) { return
No files selected
} @@ -91,19 +103,25 @@ const FileMenuOptions: FunctionComponent = ({ )} {application.featuresController.isEntitledToVaults() && ( - + )} { void application.filesController.setProtectionForFiles(hasProtectedFiles, selectedFiles) }} + disabled={areSomeFilesInReadonlySharedVault} > Password protect @@ -123,6 +141,7 @@ const FileMenuOptions: FunctionComponent = ({ onClick={() => { renameToggleCallback?.(true) }} + disabled={areSomeFilesInReadonlySharedVault} > Rename @@ -133,6 +152,7 @@ const FileMenuOptions: FunctionComponent = ({ closeMenuAndToggleFilesList() void application.filesController.deleteFilesPermanently(selectedFiles) }} + disabled={areSomeFilesInReadonlySharedVault} > Delete permanently diff --git a/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx b/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx index 77bdd153e..758bb32c3 100644 --- a/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx +++ b/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx @@ -113,6 +113,9 @@ const FilePreviewModal = observer(({ application }: Props) => { return null } + const vault = application.vaults.getItemVault(currentFile) + const isReadonly = vault?.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault) + return ( { )}
- - - + {!isReadonly && ( + + + + )}
{showLinkedBubblesContainer && (
- +
)}
diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx index ab0884ac4..c21ee8b1e 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx @@ -166,7 +166,7 @@ const LinkedItemBubblesContainer = ({ const { vault, lastEditedByContact } = useItemVaultInfo(item) - if (readonly && itemsToDisplay.length === 0) { + if (readonly && itemsToDisplay.length === 0 && !vault) { return null } diff --git a/packages/web/src/javascripts/Components/Menu/MenuItem.tsx b/packages/web/src/javascripts/Components/Menu/MenuItem.tsx index d13cb4785..3881d5605 100644 --- a/packages/web/src/javascripts/Components/Menu/MenuItem.tsx +++ b/packages/web/src/javascripts/Components/Menu/MenuItem.tsx @@ -41,7 +41,7 @@ const MenuItem = forwardRef( tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} className={classNames( 'flex w-full cursor-pointer select-none border-0 bg-transparent px-3 py-2 text-left md:py-1.5', - 'text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground', + 'text-mobile-menu-item text-text enabled:hover:bg-contrast enabled:hover:text-foreground', 'focus:bg-info-backdrop focus:shadow-none md:text-tablet-menu-item lg:text-menu-item', 'disabled:cursor-not-allowed disabled:opacity-60', className, diff --git a/packages/web/src/javascripts/Components/Menu/MenuSwitchButtonItem.tsx b/packages/web/src/javascripts/Components/Menu/MenuSwitchButtonItem.tsx index 5edceb503..9493dc1f1 100644 --- a/packages/web/src/javascripts/Components/Menu/MenuSwitchButtonItem.tsx +++ b/packages/web/src/javascripts/Components/Menu/MenuSwitchButtonItem.tsx @@ -37,8 +37,9 @@ const MenuSwitchButtonItem = forwardRef( ref={ref} className={classNames( 'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5', - 'text-left text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none', + 'text-left text-text focus:bg-info-backdrop focus:shadow-none enabled:hover:bg-contrast enabled:hover:text-foreground', 'text-mobile-menu-item md:text-tablet-menu-item lg:text-menu-item', + 'disabled:cursor-not-allowed disabled:opacity-60', className, )} onClick={() => { diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts b/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts index cae8ebb9f..a1f991618 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts @@ -10,6 +10,7 @@ import { SNNote, NoteType, PayloadEmitSource, + VaultServiceInterface, } from '@standardnotes/snjs' import NoteView from './NoteView' import { NoteViewController } from './Controller/NoteViewController' @@ -19,6 +20,7 @@ describe('NoteView', () => { let application: WebApplication let notesController: NotesController + let vaults: VaultServiceInterface const createNoteView = () => new NoteView({ @@ -36,9 +38,13 @@ describe('NoteView', () => { notesController.getSpellcheckStateForNote = jest.fn() notesController.getEditorWidthForNote = jest.fn() + vaults = {} as jest.Mocked + vaults.getItemVault = jest.fn().mockReturnValue(undefined) + application = { notesController, noteViewController, + vaults, } as unknown as jest.Mocked application.hasProtectionSources = jest.fn().mockReturnValue(true) diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index 6ed5af92b..0980c4f22 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -68,6 +68,7 @@ type State = { isDesktop?: boolean editorLineWidth: EditorLineWidth noteLocked: boolean + readonly: boolean noteStatus?: NoteStatus saveError?: boolean showProtectedWarning: boolean @@ -116,6 +117,8 @@ class NoteView extends AbstractComponent { this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25) + const itemVault = this.application.vaults.getItemVault(this.controller.item) + this.state = { availableStackComponents: [], editorStateDidLoad: false, @@ -124,6 +127,7 @@ class NoteView extends AbstractComponent { isDesktop: isDesktopApplication(), noteStatus: undefined, noteLocked: this.controller.item.locked, + readonly: itemVault ? this.application.vaultUsers.isCurrentUserReadonlyVaultMember(itemVault) : false, showProtectedWarning: false, spellcheck: true, stackComponentViewers: [], @@ -855,6 +859,13 @@ class NoteView extends AbstractComponent { /> )} + {this.state.readonly && ( +
+ + You don't have permission to edit this note +
+ )} + {this.state.noteLocked && ( this.application.notesController.setLockSelectedNotes(!this.state.noteLocked)} @@ -878,7 +889,7 @@ class NoteView extends AbstractComponent {
{ @@ -911,18 +922,22 @@ class NoteView extends AbstractComponent { )} {renderHeaderOptions && (
- - - + {!this.state.readonly && ( + <> + + + + + )} {
- +
)} @@ -961,6 +980,7 @@ class NoteView extends AbstractComponent { componentViewer={this.state.editorComponentViewer} onLoad={this.onEditorComponentLoad} requestReload={this.editorComponentViewerRequestsReload} + readonly={this.state.readonly} />
)} @@ -971,7 +991,7 @@ class NoteView extends AbstractComponent { spellcheck={this.state.spellcheck} ref={this.setPlainEditorRef} controller={this.controller} - locked={this.state.noteLocked} + locked={this.state.noteLocked || this.state.readonly} onFocus={this.onPlainFocus} onBlur={this.onPlainBlur} /> @@ -986,6 +1006,7 @@ class NoteView extends AbstractComponent { filesController={this.application.filesController} spellcheck={this.state.spellcheck} controller={this.controller} + readonly={this.state.readonly} /> )} diff --git a/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx index 61c2998f3..38565c496 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx @@ -16,6 +16,7 @@ type Props = { linkingController: LinkingController selectedItems: DecryptedItemInterface[] iconClassName: string + disabled?: boolean } const AddTagOption: FunctionComponent = ({ @@ -23,6 +24,7 @@ const AddTagOption: FunctionComponent = ({ linkingController, selectedItems, iconClassName, + disabled, }) => { const application = useApplication() const menuContainerRef = useRef(null) @@ -57,6 +59,7 @@ const AddTagOption: FunctionComponent = ({ } }} ref={buttonRef} + disabled={disabled} >
diff --git a/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx index db03dfef7..45928726a 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx @@ -12,9 +12,15 @@ type ChangeEditorOptionProps = { application: WebApplication note: SNNote iconClassName: string + disabled?: boolean } -const ChangeEditorOption: FunctionComponent = ({ application, note, iconClassName }) => { +const ChangeEditorOption: FunctionComponent = ({ + application, + note, + iconClassName, + disabled, +}) => { const [isOpen, setIsOpen] = useState(false) const menuContainerRef = useRef(null) const buttonRef = useRef(null) @@ -38,6 +44,7 @@ const ChangeEditorOption: FunctionComponent = ({ applic setIsOpen(false) } }} + disabled={disabled} ref={buttonRef} >
diff --git a/packages/web/src/javascripts/Components/NotesOptions/DeletePermanentlyButton.tsx b/packages/web/src/javascripts/Components/NotesOptions/DeletePermanentlyButton.tsx deleted file mode 100644 index 143dcff0a..000000000 --- a/packages/web/src/javascripts/Components/NotesOptions/DeletePermanentlyButton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Icon from '@/Components/Icon/Icon' -import MenuItem from '../Menu/MenuItem' - -type DeletePermanentlyButtonProps = { - onClick: () => void -} - -export const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => ( - - - Delete permanently - -) diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index 2fbe0a04e..0c716b4dd 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -30,7 +30,6 @@ import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/Keyboard import { NoteAttributes } from './NoteAttributes' import { SpellcheckOptions } from './SpellcheckOptions' import { NoteSizeWarning } from './NoteSizeWarning' -import { DeletePermanentlyButton } from './DeletePermanentlyButton' import { useCommandService } from '../CommandProvider' import { iconClass } from './ClassNames' import SuperNoteOptions from './SuperNoteOptions' @@ -209,6 +208,17 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { } const areSomeNotesInSharedVault = notes.some((note) => application.vaults.getItemVault(note)?.isSharedVaultListing()) + const areSomeNotesInReadonlySharedVault = notes.some((note) => { + const vault = application.vaults.getItemVault(note) + return vault?.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault) + }) + const hasAdminPermissionForAllSharedNotes = notes.every((note) => { + const vault = application.vaults.getItemVault(note) + if (!vault?.isSharedVaultListing()) { + return true + } + return application.vaultUsers.isCurrentUserSharedVaultAdmin(vault) + }) if (notes.length === 0) { return null @@ -220,13 +230,13 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { <> {notes.length === 1 && ( <> - + Note history {historyShortcut && } - + Editor width {editorWidthShortcut && } @@ -238,6 +248,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { onChange={(locked) => { application.notesController.setLockSelectedNotes(locked) }} + disabled={areSomeNotesInReadonlySharedVault} > Prevent editing @@ -247,6 +258,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { onChange={(hidePreviews) => { application.notesController.setHideSelectedNotePreviews(!hidePreviews) }} + disabled={areSomeNotesInReadonlySharedVault} > Show preview @@ -256,6 +268,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { onChange={(protect) => { application.notesController.setProtectSelectedNotes(protect).catch(console.error) }} + disabled={areSomeNotesInReadonlySharedVault} > Password protect @@ -263,13 +276,18 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { {notes.length === 1 && ( <> - + )} {application.featuresController.isEntitledToVaults() && ( - + )} {application.navigationController.tagsCount > 0 && ( @@ -278,12 +296,14 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { navigationController={application.navigationController} selectedItems={notes} linkingController={application.linkingController} + disabled={areSomeNotesInReadonlySharedVault} /> )} { application.notesController.setStarSelectedNotes(!starred) }} + disabled={areSomeNotesInReadonlySharedVault} > {starred ? 'Unstar' : 'Star'} @@ -295,6 +315,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { onClick={() => { application.notesController.setPinSelectedNotes(true) }} + disabled={areSomeNotesInReadonlySharedVault} > Pin to top @@ -306,6 +327,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { onClick={() => { application.notesController.setPinSelectedNotes(false) }} + disabled={areSomeNotesInReadonlySharedVault} > Unpin @@ -382,7 +404,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { )} )} - + Duplicate @@ -392,6 +414,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { await application.notesController.setArchiveSelectedNotes(true).catch(console.error) closeMenuAndToggleNotesList() }} + disabled={areSomeNotesInReadonlySharedVault} > Archive @@ -403,6 +426,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { await application.notesController.setArchiveSelectedNotes(false).catch(console.error) closeMenuAndToggleNotesList() }} + disabled={areSomeNotesInReadonlySharedVault} > Unarchive @@ -410,18 +434,23 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { )} {notTrashed && (altKeyDown ? ( - { await application.notesController.deleteNotesPermanently() closeMenuAndToggleNotesList() }} - /> + > + + Delete permanently + ) : ( { await application.notesController.setTrashSelectedNotes(true) closeMenuAndToggleNotesList() }} + disabled={areSomeNotesInReadonlySharedVault} > Move to trash @@ -434,21 +463,27 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { await application.notesController.setTrashSelectedNotes(false) closeMenuAndToggleNotesList() }} + disabled={areSomeNotesInReadonlySharedVault} > Restore - { await application.notesController.deleteNotesPermanently() closeMenuAndToggleNotesList() }} - /> + > + + Delete permanently + { await application.notesController.emptyTrash() closeMenuAndToggleNotesList() }} + disabled={areSomeNotesInReadonlySharedVault} >
@@ -485,16 +520,19 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { editorForNote={editorForNote} notesController={application.notesController} note={notes[0]} + disabled={areSomeNotesInReadonlySharedVault} /> )} - + - ) : null} + ) : ( +
+ )} diff --git a/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx index afe410539..a3013944c 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/SpellcheckOptions.tsx @@ -9,7 +9,8 @@ export const SpellcheckOptions: FunctionComponent<{ editorForNote: UIFeature notesController: NotesController note: SNNote -}> = ({ editorForNote, notesController, note }) => { + disabled?: boolean +}> = ({ editorForNote, notesController, note, disabled }) => { const spellcheckControllable = editorForNote.featureDescription.spellcheckControl const noteSpellcheck = !spellcheckControllable ? true @@ -24,7 +25,7 @@ export const SpellcheckOptions: FunctionComponent<{ onChange={() => { notesController.toggleGlobalSpellcheckForNote(note).catch(console.error) }} - disabled={!spellcheckControllable} + disabled={disabled || !spellcheckControllable} > Spellcheck diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx index c1b6e416c..df42cc2f7 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/ContactInviteModal.tsx @@ -130,7 +130,7 @@ const ContactInviteModal: FunctionComponent = ({ vault, onCloseDialog }) wrapper: 'col-start-2', }} items={Object.keys(SharedVaultUserPermission.PERMISSIONS).map((key) => ({ - label: key === 'Write' ? 'Read / Write' : key, + label: application.vaultUsers.getFormattedMemberPermission(key), value: key, }))} value={selectedContact.permission} diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx index a9e8b3780..29819cf17 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Invites/InviteItem.tsx @@ -2,11 +2,10 @@ import { useApplication } from '@/Components/ApplicationProvider' import Button from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon' import ModalOverlay from '@/Components/Modal/ModalOverlay' -import { InviteRecord, SharedVaultUserPermission } from '@standardnotes/snjs' +import { InviteRecord } from '@standardnotes/snjs' import { useCallback, useState } from 'react' import EditContactModal from '../Contacts/EditContactModal' import { CheckmarkCircle } from '../../../../UIElements/CheckmarkCircle' -import { capitalizeString } from '@/Utils' type Props = { inviteRecord: InviteRecord @@ -36,10 +35,7 @@ const InviteItem = ({ inviteRecord }: Props) => { const trustedContact = application.contacts.findSenderContactForInvite(inviteRecord.invite) - const permission = - inviteRecord.invite.permission === SharedVaultUserPermission.PERMISSIONS.Write - ? 'Read / Write' - : capitalizeString(inviteRecord.invite.permission) + const permission = application.vaultUsers.getFormattedMemberPermission(inviteRecord.invite.permission) return ( <> diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx index 032f6b392..4e751c456 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Vaults/Vaults/VaultItem.tsx @@ -23,7 +23,8 @@ const VaultItem = ({ vault }: Props) => { const [isVaultModalOpen, setIsVaultModalOpen] = useState(false) const closeVaultModal = () => setIsVaultModalOpen(false) - const { isCurrentUserAdmin, isLocked, canShowLockOption, toggleLock, ensureVaultIsUnlocked } = useVault(vault) + const { isCurrentUserAdmin, isCurrentUserOwner, isLocked, canShowLockOption, toggleLock, ensureVaultIsUnlocked } = + useVault(vault) const deleteVault = useCallback(async () => { const confirm = await application.alerts.confirm( @@ -122,8 +123,8 @@ const VaultItem = ({ vault }: Props) => {