chore: vault member permissions (#2509)

This commit is contained in:
Aman Harwara
2023-09-18 19:53:34 +05:30
committed by GitHub
parent 2af610c7bf
commit 48e7820100
32 changed files with 331 additions and 94 deletions

View File

@@ -191,7 +191,7 @@ export class VaultInviteService
public async getInvitableContactsForSharedVault( public async getInvitableContactsForSharedVault(
sharedVault: SharedVaultListingInterface, sharedVault: SharedVaultListingInterface,
): Promise<TrustedContactInterface[]> { ): Promise<TrustedContactInterface[]> {
const users = await this.vaultUsers.getSharedVaultUsers(sharedVault) const users = await this.vaultUsers.getSharedVaultUsersFromServer(sharedVault)
if (!users) { if (!users) {
return [] return []
} }

View File

@@ -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<boolean> {
constructor(
private users: UserServiceInterface,
private cache: VaultUserCache,
) {}
execute(vault: SharedVaultListingInterface): Result<boolean> {
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)
}
}

View File

@@ -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<boolean> {
constructor(
private users: UserServiceInterface,
private cache: VaultUserCache,
) {}
execute(vault: SharedVaultListingInterface): Result<boolean> {
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,
)
}
}

View File

@@ -5,21 +5,31 @@ import { InternalEventBusInterface } from './../Internal/InternalEventBusInterfa
import { RemoveVaultMember } from './UseCase/RemoveSharedVaultMember' import { RemoveVaultMember } from './UseCase/RemoveSharedVaultMember'
import { VaultServiceInterface } from '../Vault/VaultServiceInterface' import { VaultServiceInterface } from '../Vault/VaultServiceInterface'
import { GetVaultUsers } from './UseCase/GetVaultUsers' import { GetVaultUsers } from './UseCase/GetVaultUsers'
import { SharedVaultListingInterface } from '@standardnotes/models' import { SharedVaultListingInterface, VaultListingInterface } from '@standardnotes/models'
import { VaultUserServiceInterface } from './VaultUserServiceInterface' import { VaultUserServiceInterface } from './VaultUserServiceInterface'
import { ClientDisplayableError, SharedVaultUserServerHash, isClientDisplayableError } from '@standardnotes/responses' import { ClientDisplayableError, SharedVaultUserServerHash, isClientDisplayableError } from '@standardnotes/responses'
import { AbstractService } from './../Service/AbstractService' import { AbstractService } from './../Service/AbstractService'
import { VaultUserServiceEvent } from './VaultUserServiceEvent' import { VaultUserServiceEvent } from './VaultUserServiceEvent'
import { Result } from '@standardnotes/domain-core' import { Result } from '@standardnotes/domain-core'
import { IsVaultOwner } from './UseCase/IsVaultOwner' 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<VaultUserServiceEvent> implements VaultUserServiceInterface { export class VaultUserService
extends AbstractService<VaultUserServiceEvent>
implements VaultUserServiceInterface, InternalEventHandlerInterface
{
constructor( constructor(
private vaults: VaultServiceInterface, private vaults: VaultServiceInterface,
private vaultLocks: VaultLockServiceInterface, private vaultLocks: VaultLockServiceInterface,
private _getVaultUsers: GetVaultUsers, private _getVaultUsers: GetVaultUsers,
private _removeVaultMember: RemoveVaultMember, private _removeVaultMember: RemoveVaultMember,
private _isVaultOwner: IsVaultOwner, private _isVaultOwner: IsVaultOwner,
private _isVaultAdmin: IsVaultAdmin,
private _isReadonlyVaultMember: IsReadonlyVaultMember,
private _getVault: GetVault, private _getVault: GetVault,
private _leaveVault: LeaveVault, private _leaveVault: LeaveVault,
eventBus: InternalEventBusInterface, eventBus: InternalEventBusInterface,
@@ -37,7 +47,23 @@ export class VaultUserService extends AbstractService<VaultUserServiceEvent> imp
;(this._leaveVault as unknown) = undefined ;(this._leaveVault as unknown) = undefined
} }
public async getSharedVaultUsers( async handleEvent(event: InternalEventInterface): Promise<void> {
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, sharedVault: SharedVaultListingInterface,
): Promise<SharedVaultUserServerHash[] | undefined> { ): Promise<SharedVaultUserServerHash[] | undefined> {
const result = await this._getVaultUsers.execute({ const result = await this._getVaultUsers.execute({
@@ -55,6 +81,17 @@ export class VaultUserService extends AbstractService<VaultUserServiceEvent> imp
return this._isVaultOwner.execute(sharedVault).getValue() 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<Result<void>> { async removeUserFromSharedVault(sharedVault: SharedVaultListingInterface, userUuid: string): Promise<Result<void>> {
if (!this.isCurrentUserSharedVaultOwner(sharedVault)) { if (!this.isCurrentUserSharedVaultOwner(sharedVault)) {
throw new Error('Only vault admins can remove users') throw new Error('Only vault admins can remove users')
@@ -101,4 +138,17 @@ export class VaultUserService extends AbstractService<VaultUserServiceEvent> imp
void this.notifyEvent(VaultUserServiceEvent.UsersChanged) 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'
}
}
} }

View File

@@ -1,13 +1,18 @@
import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface' import { ApplicationServiceInterface } from './../Service/ApplicationServiceInterface'
import { SharedVaultListingInterface } from '@standardnotes/models' import { SharedVaultListingInterface, VaultListingInterface } from '@standardnotes/models'
import { ClientDisplayableError, SharedVaultUserServerHash } from '@standardnotes/responses' import { ClientDisplayableError, SharedVaultUserServerHash } from '@standardnotes/responses'
import { VaultUserServiceEvent } from './VaultUserServiceEvent' import { VaultUserServiceEvent } from './VaultUserServiceEvent'
import { Result } from '@standardnotes/domain-core' import { Result } from '@standardnotes/domain-core'
export interface VaultUserServiceInterface extends ApplicationServiceInterface<VaultUserServiceEvent, unknown> { export interface VaultUserServiceInterface extends ApplicationServiceInterface<VaultUserServiceEvent, unknown> {
getSharedVaultUsers(sharedVault: SharedVaultListingInterface): Promise<SharedVaultUserServerHash[] | undefined> getSharedVaultUsersFromServer(
sharedVault: SharedVaultListingInterface,
): Promise<SharedVaultUserServerHash[] | undefined>
isCurrentUserSharedVaultOwner(sharedVault: SharedVaultListingInterface): boolean isCurrentUserSharedVaultOwner(sharedVault: SharedVaultListingInterface): boolean
isCurrentUserSharedVaultAdmin(sharedVault: SharedVaultListingInterface): boolean
isCurrentUserReadonlyVaultMember(vault: VaultListingInterface): boolean
removeUserFromSharedVault(sharedVault: SharedVaultListingInterface, userUuid: string): Promise<Result<void>> removeUserFromSharedVault(sharedVault: SharedVaultListingInterface, userUuid: string): Promise<Result<void>>
leaveSharedVault(sharedVault: SharedVaultListingInterface): Promise<ClientDisplayableError | void> leaveSharedVault(sharedVault: SharedVaultListingInterface): Promise<ClientDisplayableError | void>
isVaultUserOwner(user: SharedVaultUserServerHash): boolean isVaultUserOwner(user: SharedVaultUserServerHash): boolean
getFormattedMemberPermission(permission: string): string
} }

View File

@@ -172,6 +172,8 @@ import { EncryptionOperators } from '@standardnotes/encryption'
import { AsymmetricMessagePayload, AsymmetricMessageSharedVaultInvite } from '@standardnotes/models' import { AsymmetricMessagePayload, AsymmetricMessageSharedVaultInvite } from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { AuthorizeVaultDeletion } from '@standardnotes/services/src/Domain/Vault/UseCase/AuthorizeVaultDeletion' 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 { export class Dependencies {
private factory = new Map<symbol, () => unknown>() private factory = new Map<symbol, () => unknown>()
@@ -335,6 +337,17 @@ export class Dependencies {
return new IsVaultOwner(this.get<UserService>(TYPES.UserService)) return new IsVaultOwner(this.get<UserService>(TYPES.UserService))
}) })
this.factory.set(TYPES.IsVaultAdmin, () => {
return new IsVaultAdmin(this.get<UserService>(TYPES.UserService), this.get<VaultUserCache>(TYPES.VaultUserCache))
})
this.factory.set(TYPES.IsReadonlyVaultMember, () => {
return new IsReadonlyVaultMember(
this.get<UserService>(TYPES.UserService),
this.get<VaultUserCache>(TYPES.VaultUserCache),
)
})
this.factory.set(TYPES.DecryptBackupFile, () => { this.factory.set(TYPES.DecryptBackupFile, () => {
return new DecryptBackupFile( return new DecryptBackupFile(
this.get<EncryptionService>(TYPES.EncryptionService), this.get<EncryptionService>(TYPES.EncryptionService),
@@ -844,6 +857,8 @@ export class Dependencies {
this.get<GetVaultUsers>(TYPES.GetVaultUsers), this.get<GetVaultUsers>(TYPES.GetVaultUsers),
this.get<RemoveVaultMember>(TYPES.RemoveVaultMember), this.get<RemoveVaultMember>(TYPES.RemoveVaultMember),
this.get<IsVaultOwner>(TYPES.IsVaultOwner), this.get<IsVaultOwner>(TYPES.IsVaultOwner),
this.get<IsVaultAdmin>(TYPES.IsVaultAdmin),
this.get<IsReadonlyVaultMember>(TYPES.IsReadonlyVaultMember),
this.get<GetVault>(TYPES.GetVault), this.get<GetVault>(TYPES.GetVault),
this.get<LeaveVault>(TYPES.LeaveVault), this.get<LeaveVault>(TYPES.LeaveVault),
this.get<InternalEventBus>(TYPES.InternalEventBus), this.get<InternalEventBus>(TYPES.InternalEventBus),

View File

@@ -43,6 +43,7 @@ export function RegisterApplicationServicesEvents(container: Dependencies, event
events.addEventHandler(container.get(TYPES.VaultInviteService), ApplicationEvent.Launched) events.addEventHandler(container.get(TYPES.VaultInviteService), ApplicationEvent.Launched)
events.addEventHandler(container.get(TYPES.VaultInviteService), SyncEvent.ReceivedSharedVaultInvites) events.addEventHandler(container.get(TYPES.VaultInviteService), SyncEvent.ReceivedSharedVaultInvites)
events.addEventHandler(container.get(TYPES.VaultInviteService), WebSocketsServiceEvent.UserInvitedToSharedVault) events.addEventHandler(container.get(TYPES.VaultInviteService), WebSocketsServiceEvent.UserInvitedToSharedVault)
events.addEventHandler(container.get(TYPES.VaultUserService), ApplicationEvent.CompletedFullSync)
if (container.get(TYPES.FilesBackupService)) { if (container.get(TYPES.FilesBackupService)) {
events.addEventHandler(container.get(TYPES.FilesBackupService), ApplicationEvent.ApplicationStageChanged) events.addEventHandler(container.get(TYPES.FilesBackupService), ApplicationEvent.ApplicationStageChanged)

View File

@@ -152,6 +152,8 @@ export const TYPES = {
EncryptTypeAPayloadWithKeyLookup: Symbol.for('EncryptTypeAPayloadWithKeyLookup'), EncryptTypeAPayloadWithKeyLookup: Symbol.for('EncryptTypeAPayloadWithKeyLookup'),
DecryptBackupFile: Symbol.for('DecryptBackupFile'), DecryptBackupFile: Symbol.for('DecryptBackupFile'),
IsVaultOwner: Symbol.for('IsVaultOwner'), IsVaultOwner: Symbol.for('IsVaultOwner'),
IsVaultAdmin: Symbol.for('IsVaultAdmin'),
IsReadonlyVaultMember: Symbol.for('IsReadonlyVaultMember'),
RemoveItemsFromMemory: Symbol.for('RemoveItemsFromMemory'), RemoveItemsFromMemory: Symbol.for('RemoveItemsFromMemory'),
ReencryptTypeAItems: Symbol.for('ReencryptTypeAItems'), ReencryptTypeAItems: Symbol.for('ReencryptTypeAItems'),
DecryptErroredPayloads: Symbol.for('DecryptErroredPayloads'), DecryptErroredPayloads: Symbol.for('DecryptErroredPayloads'),

View File

@@ -20,6 +20,7 @@ interface Props {
componentViewer: ComponentViewerInterface componentViewer: ComponentViewerInterface
requestReload?: (viewer: ComponentViewerInterface, force?: boolean) => void requestReload?: (viewer: ComponentViewerInterface, force?: boolean) => void
onLoad?: () => void onLoad?: () => void
readonly?: boolean
} }
/** /**
@@ -30,7 +31,7 @@ const MaxLoadThreshold = 4000
const VisibilityChangeKey = 'visibilitychange' const VisibilityChangeKey = 'visibilitychange'
const MSToWaitAfterIframeLoadToAvoidFlicker = 35 const MSToWaitAfterIframeLoadToAvoidFlicker = 35
const IframeFeatureView: FunctionComponent<Props> = ({ onLoad, componentViewer, requestReload }) => { const IframeFeatureView: FunctionComponent<Props> = ({ onLoad, componentViewer, requestReload, readonly = false }) => {
const application = useApplication() const application = useApplication()
const iframeRef = useRef<HTMLIFrameElement | null>(null) const iframeRef = useRef<HTMLIFrameElement | null>(null)
@@ -50,7 +51,7 @@ const IframeFeatureView: FunctionComponent<Props> = ({ onLoad, componentViewer,
const reloadValidityStatus = useCallback(() => { const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus()) setFeatureStatus(componentViewer.getFeatureStatus())
if (!componentViewer.lockReadonly) { if (!componentViewer.lockReadonly) {
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled) componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled || readonly)
} }
setIsComponentValid(componentViewer.shouldRender()) setIsComponentValid(componentViewer.shouldRender())
@@ -60,7 +61,7 @@ const IframeFeatureView: FunctionComponent<Props> = ({ onLoad, componentViewer,
setError(componentViewer.getError()) setError(componentViewer.getError())
setDeprecationMessage(uiFeature.deprecationMessage) setDeprecationMessage(uiFeature.deprecationMessage)
}, [componentViewer, uiFeature, featureStatus, isComponentValid, isLoading]) }, [componentViewer, isLoading, isComponentValid, uiFeature.deprecationMessage, featureStatus, readonly])
useEffect(() => { useEffect(() => {
reloadValidityStatus() reloadValidityStatus()

View File

@@ -330,9 +330,12 @@ const ContentTableView = ({ application, items }: Props) => {
setContextMenuItem(file) setContextMenuItem(file)
}, },
rowActions: (item) => { rowActions: (item) => {
const vault = application.vaults.getItemVault(item)
const isReadonly = vault?.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault)
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ItemLinksCell item={item} /> {!isReadonly && <ItemLinksCell item={item} />}
<ContextMenuCell items={[item]} /> <ContextMenuCell items={[item]} />
</div> </div>
) )

View File

@@ -69,6 +69,18 @@ const FileMenuOptions: FunctionComponent<Props> = ({
closeMenu() closeMenu()
}, [closeMenu, toggleAppPane]) }, [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) { if (selectedFiles.length === 0) {
return <div className="text-center">No files selected</div> return <div className="text-center">No files selected</div>
} }
@@ -91,19 +103,25 @@ const FileMenuOptions: FunctionComponent<Props> = ({
</> </>
)} )}
{application.featuresController.isEntitledToVaults() && ( {application.featuresController.isEntitledToVaults() && (
<AddToVaultMenuOption iconClassName={iconClass} items={selectedFiles} /> <AddToVaultMenuOption
iconClassName={iconClass}
items={selectedFiles}
disabled={!hasAdminPermissionForAllSharedFiles}
/>
)} )}
<AddTagOption <AddTagOption
navigationController={application.navigationController} navigationController={application.navigationController}
linkingController={application.linkingController} linkingController={application.linkingController}
selectedItems={selectedFiles} selectedItems={selectedFiles}
iconClassName={`text-neutral mr-2 ${MenuItemIconSize}`} iconClassName={`text-neutral mr-2 ${MenuItemIconSize}`}
disabled={areSomeFilesInReadonlySharedVault}
/> />
<MenuSwitchButtonItem <MenuSwitchButtonItem
checked={hasProtectedFiles} checked={hasProtectedFiles}
onChange={(hasProtectedFiles) => { onChange={(hasProtectedFiles) => {
void application.filesController.setProtectionForFiles(hasProtectedFiles, selectedFiles) void application.filesController.setProtectionForFiles(hasProtectedFiles, selectedFiles)
}} }}
disabled={areSomeFilesInReadonlySharedVault}
> >
<Icon type="lock" className={`mr-2 text-neutral ${MenuItemIconSize}`} /> <Icon type="lock" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
Password protect Password protect
@@ -123,6 +141,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
onClick={() => { onClick={() => {
renameToggleCallback?.(true) renameToggleCallback?.(true)
}} }}
disabled={areSomeFilesInReadonlySharedVault}
> >
<Icon type="pencil" className={`mr-2 text-neutral ${MenuItemIconSize}`} /> <Icon type="pencil" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
Rename Rename
@@ -133,6 +152,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
closeMenuAndToggleFilesList() closeMenuAndToggleFilesList()
void application.filesController.deleteFilesPermanently(selectedFiles) void application.filesController.deleteFilesPermanently(selectedFiles)
}} }}
disabled={areSomeFilesInReadonlySharedVault}
> >
<Icon type="trash" className={`mr-2 text-danger ${MenuItemIconSize}`} /> <Icon type="trash" className={`mr-2 text-danger ${MenuItemIconSize}`} />
<span className="text-danger">Delete permanently</span> <span className="text-danger">Delete permanently</span>

View File

@@ -113,6 +113,9 @@ const FilePreviewModal = observer(({ application }: Props) => {
return null return null
} }
const vault = application.vaults.getItemVault(currentFile)
const isReadonly = vault?.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault)
return ( return (
<Modal <Modal
title={currentFile.name} title={currentFile.name}
@@ -181,15 +184,17 @@ const FilePreviewModal = observer(({ application }: Props) => {
)} )}
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<StyledTooltip label="Rename file" className="!z-modal"> {!isReadonly && (
<button <StyledTooltip label="Rename file" className="!z-modal">
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast" <button
onClick={() => setIsRenaming((isRenaming) => !isRenaming)} className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
aria-label="Rename file" onClick={() => setIsRenaming((isRenaming) => !isRenaming)}
> aria-label="Rename file"
<Icon type="pencil-filled" className="text-neutral" /> >
</button> <Icon type="pencil-filled" className="text-neutral" />
</StyledTooltip> </button>
</StyledTooltip>
)}
<StyledTooltip label="Show linked items" className="!z-modal"> <StyledTooltip label="Show linked items" className="!z-modal">
<button <button
className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast" className="mr-4 flex cursor-pointer rounded border border-solid border-border bg-transparent p-1.5 hover:bg-contrast"
@@ -249,7 +254,11 @@ const FilePreviewModal = observer(({ application }: Props) => {
</div> </div>
{showLinkedBubblesContainer && ( {showLinkedBubblesContainer && (
<div className="-mt-1 min-h-0 flex-shrink-0 border-b border-border px-3.5 py-1.5"> <div className="-mt-1 min-h-0 flex-shrink-0 border-b border-border px-3.5 py-1.5">
<LinkedItemBubblesContainer linkingController={application.linkingController} item={currentFile} /> <LinkedItemBubblesContainer
linkingController={application.linkingController}
item={currentFile}
readonly={isReadonly}
/>
</div> </div>
)} )}
<div className="flex min-h-0 flex-grow flex-col-reverse md:flex-row"> <div className="flex min-h-0 flex-grow flex-col-reverse md:flex-row">

View File

@@ -166,7 +166,7 @@ const LinkedItemBubblesContainer = ({
const { vault, lastEditedByContact } = useItemVaultInfo(item) const { vault, lastEditedByContact } = useItemVaultInfo(item)
if (readonly && itemsToDisplay.length === 0) { if (readonly && itemsToDisplay.length === 0 && !vault) {
return null return null
} }

View File

@@ -41,7 +41,7 @@ const MenuItem = forwardRef(
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
className={classNames( className={classNames(
'flex w-full cursor-pointer select-none border-0 bg-transparent px-3 py-2 text-left md:py-1.5', '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', 'focus:bg-info-backdrop focus:shadow-none md:text-tablet-menu-item lg:text-menu-item',
'disabled:cursor-not-allowed disabled:opacity-60', 'disabled:cursor-not-allowed disabled:opacity-60',
className, className,

View File

@@ -37,8 +37,9 @@ const MenuSwitchButtonItem = forwardRef(
ref={ref} ref={ref}
className={classNames( className={classNames(
'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5', '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', 'text-mobile-menu-item md:text-tablet-menu-item lg:text-menu-item',
'disabled:cursor-not-allowed disabled:opacity-60',
className, className,
)} )}
onClick={() => { onClick={() => {

View File

@@ -10,6 +10,7 @@ import {
SNNote, SNNote,
NoteType, NoteType,
PayloadEmitSource, PayloadEmitSource,
VaultServiceInterface,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import NoteView from './NoteView' import NoteView from './NoteView'
import { NoteViewController } from './Controller/NoteViewController' import { NoteViewController } from './Controller/NoteViewController'
@@ -19,6 +20,7 @@ describe('NoteView', () => {
let application: WebApplication let application: WebApplication
let notesController: NotesController let notesController: NotesController
let vaults: VaultServiceInterface
const createNoteView = () => const createNoteView = () =>
new NoteView({ new NoteView({
@@ -36,9 +38,13 @@ describe('NoteView', () => {
notesController.getSpellcheckStateForNote = jest.fn() notesController.getSpellcheckStateForNote = jest.fn()
notesController.getEditorWidthForNote = jest.fn() notesController.getEditorWidthForNote = jest.fn()
vaults = {} as jest.Mocked<VaultServiceInterface>
vaults.getItemVault = jest.fn().mockReturnValue(undefined)
application = { application = {
notesController, notesController,
noteViewController, noteViewController,
vaults,
} as unknown as jest.Mocked<WebApplication> } as unknown as jest.Mocked<WebApplication>
application.hasProtectionSources = jest.fn().mockReturnValue(true) application.hasProtectionSources = jest.fn().mockReturnValue(true)

View File

@@ -68,6 +68,7 @@ type State = {
isDesktop?: boolean isDesktop?: boolean
editorLineWidth: EditorLineWidth editorLineWidth: EditorLineWidth
noteLocked: boolean noteLocked: boolean
readonly: boolean
noteStatus?: NoteStatus noteStatus?: NoteStatus
saveError?: boolean saveError?: boolean
showProtectedWarning: boolean showProtectedWarning: boolean
@@ -116,6 +117,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25) this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25)
const itemVault = this.application.vaults.getItemVault(this.controller.item)
this.state = { this.state = {
availableStackComponents: [], availableStackComponents: [],
editorStateDidLoad: false, editorStateDidLoad: false,
@@ -124,6 +127,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
isDesktop: isDesktopApplication(), isDesktop: isDesktopApplication(),
noteStatus: undefined, noteStatus: undefined,
noteLocked: this.controller.item.locked, noteLocked: this.controller.item.locked,
readonly: itemVault ? this.application.vaultUsers.isCurrentUserReadonlyVaultMember(itemVault) : false,
showProtectedWarning: false, showProtectedWarning: false,
spellcheck: true, spellcheck: true,
stackComponentViewers: [], stackComponentViewers: [],
@@ -855,6 +859,13 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
/> />
)} )}
{this.state.readonly && (
<div className="bg-warning-faded flex items-center px-3.5 py-2 text-sm text-accessory-tint-3">
<Icon type="pencil-off" className="mr-3" />
You don't have permission to edit this note
</div>
)}
{this.state.noteLocked && ( {this.state.noteLocked && (
<EditingDisabledBanner <EditingDisabledBanner
onClick={() => this.application.notesController.setLockSelectedNotes(!this.state.noteLocked)} onClick={() => this.application.notesController.setLockSelectedNotes(!this.state.noteLocked)}
@@ -878,7 +889,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
<div className="title flex-grow overflow-auto"> <div className="title flex-grow overflow-auto">
<input <input
className="input text-lg" className="input text-lg"
disabled={this.state.noteLocked} disabled={this.state.noteLocked || this.state.readonly}
id={ElementIds.NoteTitleEditor} id={ElementIds.NoteTitleEditor}
onChange={this.onTitleChange} onChange={this.onTitleChange}
onFocus={(event) => { onFocus={(event) => {
@@ -911,18 +922,22 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)} )}
{renderHeaderOptions && ( {renderHeaderOptions && (
<div className="note-view-options-buttons flex items-center gap-3"> <div className="note-view-options-buttons flex items-center gap-3">
<LinkedItemsButton {!this.state.readonly && (
linkingController={this.application.linkingController} <>
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} <LinkedItemsButton
/> linkingController={this.application.linkingController}
<ChangeEditorButton onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
noteViewController={this.controller} />
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} <ChangeEditorButton
/> noteViewController={this.controller}
<PinNoteButton onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
notesController={this.application.notesController} />
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} <PinNoteButton
/> notesController={this.application.notesController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</>
)}
<NotesOptionsPanel <NotesOptionsPanel
notesController={this.application.notesController} notesController={this.application.notesController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
@@ -934,7 +949,11 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
<CollaborationInfoHUD item={this.note} /> <CollaborationInfoHUD item={this.note} />
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
<LinkedItemBubblesContainer item={this.note} linkingController={this.application.linkingController} /> <LinkedItemBubblesContainer
item={this.note}
linkingController={this.application.linkingController}
readonly={this.state.readonly}
/>
</div> </div>
</div> </div>
)} )}
@@ -961,6 +980,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
componentViewer={this.state.editorComponentViewer} componentViewer={this.state.editorComponentViewer}
onLoad={this.onEditorComponentLoad} onLoad={this.onEditorComponentLoad}
requestReload={this.editorComponentViewerRequestsReload} requestReload={this.editorComponentViewerRequestsReload}
readonly={this.state.readonly}
/> />
</div> </div>
)} )}
@@ -971,7 +991,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
spellcheck={this.state.spellcheck} spellcheck={this.state.spellcheck}
ref={this.setPlainEditorRef} ref={this.setPlainEditorRef}
controller={this.controller} controller={this.controller}
locked={this.state.noteLocked} locked={this.state.noteLocked || this.state.readonly}
onFocus={this.onPlainFocus} onFocus={this.onPlainFocus}
onBlur={this.onPlainBlur} onBlur={this.onPlainBlur}
/> />
@@ -986,6 +1006,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
filesController={this.application.filesController} filesController={this.application.filesController}
spellcheck={this.state.spellcheck} spellcheck={this.state.spellcheck}
controller={this.controller} controller={this.controller}
readonly={this.state.readonly}
/> />
</div> </div>
)} )}

View File

@@ -16,6 +16,7 @@ type Props = {
linkingController: LinkingController linkingController: LinkingController
selectedItems: DecryptedItemInterface[] selectedItems: DecryptedItemInterface[]
iconClassName: string iconClassName: string
disabled?: boolean
} }
const AddTagOption: FunctionComponent<Props> = ({ const AddTagOption: FunctionComponent<Props> = ({
@@ -23,6 +24,7 @@ const AddTagOption: FunctionComponent<Props> = ({
linkingController, linkingController,
selectedItems, selectedItems,
iconClassName, iconClassName,
disabled,
}) => { }) => {
const application = useApplication() const application = useApplication()
const menuContainerRef = useRef<HTMLDivElement>(null) const menuContainerRef = useRef<HTMLDivElement>(null)
@@ -57,6 +59,7 @@ const AddTagOption: FunctionComponent<Props> = ({
} }
}} }}
ref={buttonRef} ref={buttonRef}
disabled={disabled}
> >
<div className="flex items-center"> <div className="flex items-center">
<Icon type="hashtag" className={iconClassName} /> <Icon type="hashtag" className={iconClassName} />

View File

@@ -12,9 +12,15 @@ type ChangeEditorOptionProps = {
application: WebApplication application: WebApplication
note: SNNote note: SNNote
iconClassName: string iconClassName: string
disabled?: boolean
} }
const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ application, note, iconClassName }) => { const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
application,
note,
iconClassName,
disabled,
}) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const menuContainerRef = useRef<HTMLDivElement>(null) const menuContainerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
@@ -38,6 +44,7 @@ const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ applic
setIsOpen(false) setIsOpen(false)
} }
}} }}
disabled={disabled}
ref={buttonRef} ref={buttonRef}
> >
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -1,13 +0,0 @@
import Icon from '@/Components/Icon/Icon'
import MenuItem from '../Menu/MenuItem'
type DeletePermanentlyButtonProps = {
onClick: () => void
}
export const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
<MenuItem onClick={onClick}>
<Icon type="close" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</MenuItem>
)

View File

@@ -30,7 +30,6 @@ import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/Keyboard
import { NoteAttributes } from './NoteAttributes' import { NoteAttributes } from './NoteAttributes'
import { SpellcheckOptions } from './SpellcheckOptions' import { SpellcheckOptions } from './SpellcheckOptions'
import { NoteSizeWarning } from './NoteSizeWarning' import { NoteSizeWarning } from './NoteSizeWarning'
import { DeletePermanentlyButton } from './DeletePermanentlyButton'
import { useCommandService } from '../CommandProvider' import { useCommandService } from '../CommandProvider'
import { iconClass } from './ClassNames' import { iconClass } from './ClassNames'
import SuperNoteOptions from './SuperNoteOptions' import SuperNoteOptions from './SuperNoteOptions'
@@ -209,6 +208,17 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
} }
const areSomeNotesInSharedVault = notes.some((note) => application.vaults.getItemVault(note)?.isSharedVaultListing()) 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) { if (notes.length === 0) {
return null return null
@@ -220,13 +230,13 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<> <>
{notes.length === 1 && ( {notes.length === 1 && (
<> <>
<MenuItem onClick={openRevisionHistoryModal}> <MenuItem onClick={openRevisionHistoryModal} disabled={areSomeNotesInReadonlySharedVault}>
<Icon type="history" className={iconClass} /> <Icon type="history" className={iconClass} />
Note history Note history
{historyShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={historyShortcut} />} {historyShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={historyShortcut} />}
</MenuItem> </MenuItem>
<HorizontalSeparator classes="my-2" /> <HorizontalSeparator classes="my-2" />
<MenuItem onClick={toggleLineWidthModal}> <MenuItem onClick={toggleLineWidthModal} disabled={areSomeNotesInReadonlySharedVault}>
<Icon type="line-width" className={iconClass} /> <Icon type="line-width" className={iconClass} />
Editor width Editor width
{editorWidthShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={editorWidthShortcut} />} {editorWidthShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={editorWidthShortcut} />}
@@ -238,6 +248,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
onChange={(locked) => { onChange={(locked) => {
application.notesController.setLockSelectedNotes(locked) application.notesController.setLockSelectedNotes(locked)
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="pencil-off" className={iconClass} /> <Icon type="pencil-off" className={iconClass} />
Prevent editing Prevent editing
@@ -247,6 +258,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
onChange={(hidePreviews) => { onChange={(hidePreviews) => {
application.notesController.setHideSelectedNotePreviews(!hidePreviews) application.notesController.setHideSelectedNotePreviews(!hidePreviews)
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="rich-text" className={iconClass} /> <Icon type="rich-text" className={iconClass} />
Show preview Show preview
@@ -256,6 +268,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
onChange={(protect) => { onChange={(protect) => {
application.notesController.setProtectSelectedNotes(protect).catch(console.error) application.notesController.setProtectSelectedNotes(protect).catch(console.error)
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="lock" className={iconClass} /> <Icon type="lock" className={iconClass} />
Password protect Password protect
@@ -263,13 +276,18 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{notes.length === 1 && ( {notes.length === 1 && (
<> <>
<HorizontalSeparator classes="my-2" /> <HorizontalSeparator classes="my-2" />
<ChangeEditorOption iconClassName={iconClass} application={application} note={notes[0]} /> <ChangeEditorOption
iconClassName={iconClass}
application={application}
note={notes[0]}
disabled={areSomeNotesInReadonlySharedVault}
/>
</> </>
)} )}
<HorizontalSeparator classes="my-2" /> <HorizontalSeparator classes="my-2" />
{application.featuresController.isEntitledToVaults() && ( {application.featuresController.isEntitledToVaults() && (
<AddToVaultMenuOption iconClassName={iconClass} items={notes} /> <AddToVaultMenuOption iconClassName={iconClass} items={notes} disabled={!hasAdminPermissionForAllSharedNotes} />
)} )}
{application.navigationController.tagsCount > 0 && ( {application.navigationController.tagsCount > 0 && (
@@ -278,12 +296,14 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
navigationController={application.navigationController} navigationController={application.navigationController}
selectedItems={notes} selectedItems={notes}
linkingController={application.linkingController} linkingController={application.linkingController}
disabled={areSomeNotesInReadonlySharedVault}
/> />
)} )}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
application.notesController.setStarSelectedNotes(!starred) application.notesController.setStarSelectedNotes(!starred)
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="star" className={iconClass} /> <Icon type="star" className={iconClass} />
{starred ? 'Unstar' : 'Star'} {starred ? 'Unstar' : 'Star'}
@@ -295,6 +315,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
onClick={() => { onClick={() => {
application.notesController.setPinSelectedNotes(true) application.notesController.setPinSelectedNotes(true)
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="pin" className={iconClass} /> <Icon type="pin" className={iconClass} />
Pin to top Pin to top
@@ -306,6 +327,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
onClick={() => { onClick={() => {
application.notesController.setPinSelectedNotes(false) application.notesController.setPinSelectedNotes(false)
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="unpin" className={iconClass} /> <Icon type="unpin" className={iconClass} />
Unpin Unpin
@@ -382,7 +404,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
)} )}
</> </>
)} )}
<MenuItem onClick={duplicateSelectedItems}> <MenuItem onClick={duplicateSelectedItems} disabled={areSomeNotesInReadonlySharedVault}>
<Icon type="copy" className={iconClass} /> <Icon type="copy" className={iconClass} />
Duplicate Duplicate
</MenuItem> </MenuItem>
@@ -392,6 +414,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
await application.notesController.setArchiveSelectedNotes(true).catch(console.error) await application.notesController.setArchiveSelectedNotes(true).catch(console.error)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="archive" className={iconClassWarning} /> <Icon type="archive" className={iconClassWarning} />
<span className="text-warning">Archive</span> <span className="text-warning">Archive</span>
@@ -403,6 +426,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
await application.notesController.setArchiveSelectedNotes(false).catch(console.error) await application.notesController.setArchiveSelectedNotes(false).catch(console.error)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="unarchive" className={iconClassWarning} /> <Icon type="unarchive" className={iconClassWarning} />
<span className="text-warning">Unarchive</span> <span className="text-warning">Unarchive</span>
@@ -410,18 +434,23 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
)} )}
{notTrashed && {notTrashed &&
(altKeyDown ? ( (altKeyDown ? (
<DeletePermanentlyButton <MenuItem
disabled={areSomeNotesInReadonlySharedVault}
onClick={async () => { onClick={async () => {
await application.notesController.deleteNotesPermanently() await application.notesController.deleteNotesPermanently()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
/> >
<Icon type="close" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</MenuItem>
) : ( ) : (
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.setTrashSelectedNotes(true) await application.notesController.setTrashSelectedNotes(true)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="trash" className={iconClassDanger} /> <Icon type="trash" className={iconClassDanger} />
<span className="text-danger">Move to trash</span> <span className="text-danger">Move to trash</span>
@@ -434,21 +463,27 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
await application.notesController.setTrashSelectedNotes(false) await application.notesController.setTrashSelectedNotes(false)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<Icon type="restore" className={iconClassSuccess} /> <Icon type="restore" className={iconClassSuccess} />
<span className="text-success">Restore</span> <span className="text-success">Restore</span>
</MenuItem> </MenuItem>
<DeletePermanentlyButton <MenuItem
disabled={areSomeNotesInReadonlySharedVault}
onClick={async () => { onClick={async () => {
await application.notesController.deleteNotesPermanently() await application.notesController.deleteNotesPermanently()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
/> >
<Icon type="close" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</MenuItem>
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.emptyTrash() await application.notesController.emptyTrash()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault}
> >
<div className="flex items-start"> <div className="flex items-start">
<Icon type="trash-sweep" className="mr-2 text-danger" /> <Icon type="trash-sweep" className="mr-2 text-danger" />
@@ -485,16 +520,19 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
editorForNote={editorForNote} editorForNote={editorForNote}
notesController={application.notesController} notesController={application.notesController}
note={notes[0]} note={notes[0]}
disabled={areSomeNotesInReadonlySharedVault}
/> />
)} )}
<HorizontalSeparator classes="my-2" /> <HorizontalSeparator classes="my-2" />
<NoteAttributes application={application} note={notes[0]} /> <NoteAttributes className="mb-2" application={application} note={notes[0]} />
<NoteSizeWarning note={notes[0]} /> <NoteSizeWarning note={notes[0]} />
</> </>
) : null} ) : (
<div className="h-2" />
)}
<ModalOverlay isOpen={showExportSuperModal} close={closeSuperExportModal}> <ModalOverlay isOpen={showExportSuperModal} close={closeSuperExportModal}>
<SuperExportModal exportNotes={downloadSelectedItems} close={closeSuperExportModal} /> <SuperExportModal exportNotes={downloadSelectedItems} close={closeSuperExportModal} />

View File

@@ -9,7 +9,8 @@ export const SpellcheckOptions: FunctionComponent<{
editorForNote: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription> editorForNote: UIFeature<EditorFeatureDescription | IframeComponentFeatureDescription>
notesController: NotesController notesController: NotesController
note: SNNote note: SNNote
}> = ({ editorForNote, notesController, note }) => { disabled?: boolean
}> = ({ editorForNote, notesController, note, disabled }) => {
const spellcheckControllable = editorForNote.featureDescription.spellcheckControl const spellcheckControllable = editorForNote.featureDescription.spellcheckControl
const noteSpellcheck = !spellcheckControllable const noteSpellcheck = !spellcheckControllable
? true ? true
@@ -24,7 +25,7 @@ export const SpellcheckOptions: FunctionComponent<{
onChange={() => { onChange={() => {
notesController.toggleGlobalSpellcheckForNote(note).catch(console.error) notesController.toggleGlobalSpellcheckForNote(note).catch(console.error)
}} }}
disabled={!spellcheckControllable} disabled={disabled || !spellcheckControllable}
> >
<Icon type="notes" className={iconClass} /> <Icon type="notes" className={iconClass} />
Spellcheck Spellcheck

View File

@@ -130,7 +130,7 @@ const ContactInviteModal: FunctionComponent<Props> = ({ vault, onCloseDialog })
wrapper: 'col-start-2', wrapper: 'col-start-2',
}} }}
items={Object.keys(SharedVaultUserPermission.PERMISSIONS).map((key) => ({ items={Object.keys(SharedVaultUserPermission.PERMISSIONS).map((key) => ({
label: key === 'Write' ? 'Read / Write' : key, label: application.vaultUsers.getFormattedMemberPermission(key),
value: key, value: key,
}))} }))}
value={selectedContact.permission} value={selectedContact.permission}

View File

@@ -2,11 +2,10 @@ import { useApplication } from '@/Components/ApplicationProvider'
import Button from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import ModalOverlay from '@/Components/Modal/ModalOverlay' import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { InviteRecord, SharedVaultUserPermission } from '@standardnotes/snjs' import { InviteRecord } from '@standardnotes/snjs'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import EditContactModal from '../Contacts/EditContactModal' import EditContactModal from '../Contacts/EditContactModal'
import { CheckmarkCircle } from '../../../../UIElements/CheckmarkCircle' import { CheckmarkCircle } from '../../../../UIElements/CheckmarkCircle'
import { capitalizeString } from '@/Utils'
type Props = { type Props = {
inviteRecord: InviteRecord inviteRecord: InviteRecord
@@ -36,10 +35,7 @@ const InviteItem = ({ inviteRecord }: Props) => {
const trustedContact = application.contacts.findSenderContactForInvite(inviteRecord.invite) const trustedContact = application.contacts.findSenderContactForInvite(inviteRecord.invite)
const permission = const permission = application.vaultUsers.getFormattedMemberPermission(inviteRecord.invite.permission)
inviteRecord.invite.permission === SharedVaultUserPermission.PERMISSIONS.Write
? 'Read / Write'
: capitalizeString(inviteRecord.invite.permission)
return ( return (
<> <>

View File

@@ -23,7 +23,8 @@ const VaultItem = ({ vault }: Props) => {
const [isVaultModalOpen, setIsVaultModalOpen] = useState(false) const [isVaultModalOpen, setIsVaultModalOpen] = useState(false)
const closeVaultModal = () => setIsVaultModalOpen(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 deleteVault = useCallback(async () => {
const confirm = await application.alerts.confirm( const confirm = await application.alerts.confirm(
@@ -122,8 +123,8 @@ const VaultItem = ({ vault }: Props) => {
<div className="mt-2 flex w-full flex-wrap gap-3"> <div className="mt-2 flex w-full flex-wrap gap-3">
<Button label="Edit" onClick={openEditModal} /> <Button label="Edit" onClick={openEditModal} />
{canShowLockOption && <Button label={isLocked ? 'Unlock' : 'Lock'} onClick={toggleLock} />} {canShowLockOption && <Button label={isLocked ? 'Unlock' : 'Lock'} onClick={toggleLock} />}
{isCurrentUserAdmin && <Button colorStyle="danger" label="Delete" onClick={deleteVault} />} {isCurrentUserOwner && <Button colorStyle="danger" label="Delete" onClick={deleteVault} />}
{!isCurrentUserAdmin && vault.isSharedVaultListing() && <Button label="Leave Vault" onClick={leaveVault} />} {!isCurrentUserOwner && vault.isSharedVaultListing() && <Button label="Leave Vault" onClick={leaveVault} />}
{isCurrentUserAdmin ? ( {isCurrentUserAdmin ? (
vault.isSharedVaultListing() ? ( vault.isSharedVaultListing() ? (
<Button colorStyle="info" label="Invite Contacts" onClick={openInviteModal} /> <Button colorStyle="info" label="Invite Contacts" onClick={openInviteModal} />

View File

@@ -66,11 +66,11 @@ const EditVaultModalContent: FunctionComponent<{
if (existingVault.isSharedVaultListing()) { if (existingVault.isSharedVaultListing()) {
setIsAdmin( setIsAdmin(
existingVault.isSharedVaultListing() && application.vaultUsers.isCurrentUserSharedVaultOwner(existingVault), existingVault.isSharedVaultListing() && application.vaultUsers.isCurrentUserSharedVaultAdmin(existingVault),
) )
setIsLoadingCollaborationInfo(true) setIsLoadingCollaborationInfo(true)
const users = await application.vaultUsers.getSharedVaultUsers(existingVault) const users = await application.vaultUsers.getSharedVaultUsersFromServer(existingVault)
if (users) { if (users) {
setMembers(users) setMembers(users)
} }

View File

@@ -1,9 +1,8 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useApplication } from '@/Components/ApplicationProvider' import { useApplication } from '@/Components/ApplicationProvider'
import { SharedVaultInviteServerHash, SharedVaultUserPermission } from '@standardnotes/snjs' import { SharedVaultInviteServerHash } from '@standardnotes/snjs'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import Button from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { capitalizeString } from '@/Utils'
export const VaultModalInvites = ({ export const VaultModalInvites = ({
invites, invites,
@@ -30,10 +29,7 @@ export const VaultModalInvites = ({
<div className="space-y-3.5"> <div className="space-y-3.5">
{invites.map((invite) => { {invites.map((invite) => {
const contact = application.contacts.findContactForInvite(invite) const contact = application.contacts.findContactForInvite(invite)
const permission = const permission = application.vaultUsers.getFormattedMemberPermission(invite.permission)
invite.permission === SharedVaultUserPermission.PERMISSIONS.Write
? 'Read / Write'
: capitalizeString(invite.permission)
return ( return (
<div <div

View File

@@ -1,9 +1,8 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useApplication } from '@/Components/ApplicationProvider' import { useApplication } from '@/Components/ApplicationProvider'
import { SharedVaultUserPermission, SharedVaultUserServerHash, VaultListingInterface } from '@standardnotes/snjs' import { SharedVaultUserServerHash, VaultListingInterface } from '@standardnotes/snjs'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import Button from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import { capitalizeString } from '@/Utils'
export const VaultModalMembers = ({ export const VaultModalMembers = ({
members, members,
@@ -35,10 +34,7 @@ export const VaultModalMembers = ({
{members.map((member) => { {members.map((member) => {
const isMemberVaultOwner = application.vaultUsers.isVaultUserOwner(member) const isMemberVaultOwner = application.vaultUsers.isVaultUserOwner(member)
const contact = application.contacts.findContactForServerUser(member) const contact = application.contacts.findContactForServerUser(member)
const permission = const permission = application.vaultUsers.getFormattedMemberPermission(member.permission)
member.permission === SharedVaultUserPermission.PERMISSIONS.Write
? 'Read / Write'
: capitalizeString(member.permission)
return ( return (
<div <div

View File

@@ -25,6 +25,11 @@ const CodeOptionsPlugin = () => {
const [selectedElementKey, setSelectedElementKey] = useState<NodeKey | null>(null) const [selectedElementKey, setSelectedElementKey] = useState<NodeKey | null>(null)
const updateToolbar = useCallback(() => { const updateToolbar = useCallback(() => {
if (!editor.isEditable()) {
setIsCode(false)
return
}
const selection = $getSelection() const selection = $getSelection()
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return return

View File

@@ -58,6 +58,7 @@ type Props = {
linkingController: LinkingController linkingController: LinkingController
filesController: FilesController filesController: FilesController
spellcheck: boolean spellcheck: boolean
readonly?: boolean
} }
export const SuperEditor: FunctionComponent<Props> = ({ export const SuperEditor: FunctionComponent<Props> = ({
@@ -66,6 +67,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
filesController, filesController,
spellcheck, spellcheck,
controller, controller,
readonly,
}) => { }) => {
const note = useRef(controller.item) const note = useRef(controller.item)
const changeEditorFunction = useRef<ChangeEditorFunction>() const changeEditorFunction = useRef<ChangeEditorFunction>()
@@ -217,7 +219,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<ErrorBoundary> <ErrorBoundary>
<LinkingControllerProvider controller={linkingController}> <LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}> <FilesControllerProvider controller={filesController}>
<BlocksEditorComposer readonly={note.current.locked} initialValue={note.current.text}> <BlocksEditorComposer readonly={note.current.locked || readonly} initialValue={note.current.text}>
<BlocksEditor <BlocksEditor
onChange={handleChange} onChange={handleChange}
className={classNames( className={classNames(
@@ -227,6 +229,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
)} )}
previewLength={SuperNotePreviewCharLimit} previewLength={SuperNotePreviewCharLimit}
spellcheck={spellcheck} spellcheck={spellcheck}
readonly={note.current.locked || readonly}
> >
<ItemSelectionPlugin currentNote={note.current} /> <ItemSelectionPlugin currentNote={note.current} />
<FilePlugin currentNote={note.current} /> <FilePlugin currentNote={note.current} />
@@ -242,7 +245,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} /> <NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} /> <NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin /> <ExportPlugin />
<ReadonlyPlugin note={note.current} /> {readonly === undefined && <ReadonlyPlugin note={note.current} />}
<AutoFocusPlugin isEnabled={controller.isTemplateNote} /> <AutoFocusPlugin isEnabled={controller.isTemplateNote} />
<SuperSearchContextProvider> <SuperSearchContextProvider>
<SearchPlugin /> <SearchPlugin />

View File

@@ -78,6 +78,7 @@ const VaultMenu = observer(({ items }: { items: DecryptedItemInterface[] }) => {
doesVaultContainItems(vault) ? void removeItemsFromVault() : void addItemsToVault(vault) doesVaultContainItems(vault) ? void removeItemsFromVault() : void addItemsToVault(vault)
}} }}
className={doesVaultContainItems(vault) ? 'font-bold' : ''} className={doesVaultContainItems(vault) ? 'font-bold' : ''}
disabled={vault.isSharedVaultListing() && application.vaultUsers.isCurrentUserReadonlyVaultMember(vault)}
> >
<Icon <Icon
type={vault.iconString} type={vault.iconString}
@@ -98,7 +99,15 @@ const VaultMenu = observer(({ items }: { items: DecryptedItemInterface[] }) => {
) )
}) })
const AddToVaultMenuOption = ({ iconClassName, items }: { iconClassName: string; items: DecryptedItemInterface[] }) => { const AddToVaultMenuOption = ({
iconClassName,
items,
disabled,
}: {
iconClassName: string
items: DecryptedItemInterface[]
disabled?: boolean
}) => {
const application = useApplication() const application = useApplication()
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
@@ -123,6 +132,7 @@ const AddToVaultMenuOption = ({ iconClassName, items }: { iconClassName: string;
} }
}} }}
ref={buttonRef} ref={buttonRef}
disabled={disabled}
> >
<div className="flex items-center"> <div className="flex items-center">
<Icon type="safe-square" className={iconClassName} /> <Icon type="safe-square" className={iconClassName} />

View File

@@ -37,6 +37,9 @@ export const useVault = (vault: VaultListingInterface) => {
}, [application.vaultDisplayService, application.vaultLocks, canShowLockOption, isLocked, vault]) }, [application.vaultDisplayService, application.vaultLocks, canShowLockOption, isLocked, vault])
const isCurrentUserAdmin = !vault.isSharedVaultListing() const isCurrentUserAdmin = !vault.isSharedVaultListing()
? true
: application.vaultUsers.isCurrentUserSharedVaultAdmin(vault)
const isCurrentUserOwner = !vault.isSharedVaultListing()
? true ? true
: application.vaultUsers.isCurrentUserSharedVaultOwner(vault) : application.vaultUsers.isCurrentUserSharedVaultOwner(vault)
@@ -54,5 +57,6 @@ export const useVault = (vault: VaultListingInterface) => {
toggleLock, toggleLock,
ensureVaultIsUnlocked, ensureVaultIsUnlocked,
isCurrentUserAdmin, isCurrentUserAdmin,
isCurrentUserOwner,
} }
} }