chore: ui for designating survivor for shared vault & warning when no survivor (#2544)
This commit is contained in:
@@ -4,4 +4,5 @@ export interface SharedVaultUserServerHash {
|
|||||||
user_uuid: string
|
user_uuid: string
|
||||||
permission: string
|
permission: string
|
||||||
updated_at_timestamp: number
|
updated_at_timestamp: number
|
||||||
|
is_designated_survivor: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ describe('SharedVaultService', () => {
|
|||||||
eventBus.addEventHandler = jest.fn()
|
eventBus.addEventHandler = jest.fn()
|
||||||
|
|
||||||
service = new SharedVaultService(
|
service = new SharedVaultService(
|
||||||
|
sync,
|
||||||
items,
|
items,
|
||||||
session,
|
session,
|
||||||
vaultUsers,
|
vaultUsers,
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ import { FindContact } from '../Contacts/UseCase/FindContact'
|
|||||||
import { GetOwnedSharedVaults } from './UseCase/GetOwnedSharedVaults'
|
import { GetOwnedSharedVaults } from './UseCase/GetOwnedSharedVaults'
|
||||||
import { SyncLocalVaultsWithRemoteSharedVaults } from './UseCase/SyncLocalVaultsWithRemoteSharedVaults'
|
import { SyncLocalVaultsWithRemoteSharedVaults } from './UseCase/SyncLocalVaultsWithRemoteSharedVaults'
|
||||||
import { VaultUserServiceInterface } from '../VaultUser/VaultUserServiceInterface'
|
import { VaultUserServiceInterface } from '../VaultUser/VaultUserServiceInterface'
|
||||||
|
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
||||||
|
|
||||||
export class SharedVaultService
|
export class SharedVaultService
|
||||||
extends AbstractService<SharedVaultServiceEvent, SharedVaultServiceEventPayload>
|
extends AbstractService<SharedVaultServiceEvent, SharedVaultServiceEventPayload>
|
||||||
implements SharedVaultServiceInterface, InternalEventHandlerInterface
|
implements SharedVaultServiceInterface, InternalEventHandlerInterface
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
|
private sync: SyncServiceInterface,
|
||||||
private items: ItemManagerInterface,
|
private items: ItemManagerInterface,
|
||||||
private session: SessionsClientInterface,
|
private session: SessionsClientInterface,
|
||||||
private vaultUsers: VaultUserServiceInterface,
|
private vaultUsers: VaultUserServiceInterface,
|
||||||
@@ -123,9 +125,13 @@ export class SharedVaultService
|
|||||||
.invalidateVaultUsersCache(event.eventPayload.props.primaryIdentifier.value)
|
.invalidateVaultUsersCache(event.eventPayload.props.primaryIdentifier.value)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
|
||||||
await this._syncLocalVaultsWithRemoteSharedVaults.execute([vault])
|
this.sync
|
||||||
|
.sync()
|
||||||
void this.notifyEvent(SharedVaultServiceEvent.SharedVaultStatusChanged)
|
.then(async () => {
|
||||||
|
await this._syncLocalVaultsWithRemoteSharedVaults.execute([vault])
|
||||||
|
void this.notifyEvent(SharedVaultServiceEvent.SharedVaultStatusChanged)
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -921,6 +921,7 @@ export class Dependencies {
|
|||||||
|
|
||||||
this.factory.set(TYPES.SharedVaultService, () => {
|
this.factory.set(TYPES.SharedVaultService, () => {
|
||||||
return new SharedVaultService(
|
return new SharedVaultService(
|
||||||
|
this.get<SyncService>(TYPES.SyncService),
|
||||||
this.get<ItemManager>(TYPES.ItemManager),
|
this.get<ItemManager>(TYPES.ItemManager),
|
||||||
this.get<SessionManager>(TYPES.SessionManager),
|
this.get<SessionManager>(TYPES.SessionManager),
|
||||||
this.get<VaultUserService>(TYPES.VaultUserService),
|
this.get<VaultUserService>(TYPES.VaultUserService),
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ export class VaultsContext extends AppContext {
|
|||||||
await this.awaitPromiseOrDoNothing(
|
await this.awaitPromiseOrDoNothing(
|
||||||
promise,
|
promise,
|
||||||
1,
|
1,
|
||||||
'Waiting for notifications timed out. Notifications might have been processed in previous sync.'
|
'Waiting for notifications timed out. Notifications might have been processed in previous sync.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await this.sync()
|
||||||
|
|
||||||
if (this.notifications['handleReceivedNotifications'].restore) {
|
if (this.notifications['handleReceivedNotifications'].restore) {
|
||||||
this.notifications['handleReceivedNotifications'].restore()
|
this.notifications['handleReceivedNotifications'].restore()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useApplication } from '@/Components/ApplicationProvider'
|
||||||
|
import Modal, { ModalAction } from '@/Components/Modal/Modal'
|
||||||
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
|
import { SharedVaultUserServerHash, VaultListingInterface } from '@standardnotes/snjs'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
const DesignateSurvivorModal = ({
|
||||||
|
vault,
|
||||||
|
members,
|
||||||
|
closeModal,
|
||||||
|
}: {
|
||||||
|
vault: VaultListingInterface
|
||||||
|
members: SharedVaultUserServerHash[]
|
||||||
|
closeModal: () => void
|
||||||
|
}) => {
|
||||||
|
const application = useApplication()
|
||||||
|
const [selectedSurvivor, setSelectedSurvivor] = useState<SharedVaultUserServerHash | null>(null)
|
||||||
|
const [isDesignating, setIsDesignating] = useState(false)
|
||||||
|
|
||||||
|
const designateSelectedSurvivor = useCallback(async () => {
|
||||||
|
if (!selectedSurvivor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vault.isSharedVaultListing()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDesignating(true)
|
||||||
|
const result = await application.vaultUsers.designateSurvivor(vault, selectedSurvivor.user_uuid)
|
||||||
|
if (result.isFailed()) {
|
||||||
|
throw new Error(result.getError())
|
||||||
|
}
|
||||||
|
await application.sync.sync()
|
||||||
|
closeModal()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setIsDesignating(false)
|
||||||
|
}
|
||||||
|
}, [application.sync, application.vaultUsers, closeModal, selectedSurvivor, vault])
|
||||||
|
|
||||||
|
const modalActions = useMemo(
|
||||||
|
(): ModalAction[] => [
|
||||||
|
{
|
||||||
|
label: isDesignating ? <Spinner className="h-5 w-5 border-info-contrast" /> : 'Designate survivor',
|
||||||
|
onClick: designateSelectedSurvivor,
|
||||||
|
type: 'primary',
|
||||||
|
mobileSlot: 'right',
|
||||||
|
disabled: !selectedSurvivor || isDesignating,
|
||||||
|
hidden: members.length === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: closeModal,
|
||||||
|
type: 'cancel',
|
||||||
|
mobileSlot: 'left',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[closeModal, designateSelectedSurvivor, isDesignating, members.length, selectedSurvivor],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Designate survivor" close={closeModal} actions={modalActions} className="px-4.5 py-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{members.map((member) => {
|
||||||
|
const isSelected = selectedSurvivor?.uuid === member.uuid
|
||||||
|
const contact = application.contacts.findContactForServerUser(member)
|
||||||
|
if (!contact) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const isOwner = application.vaultUsers.isVaultUserOwner(member)
|
||||||
|
if (isOwner) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5" key={member.uuid}>
|
||||||
|
<input
|
||||||
|
className="h-4 w-4 self-center accent-info"
|
||||||
|
type="radio"
|
||||||
|
name="survivor"
|
||||||
|
checked={isSelected}
|
||||||
|
onClick={() => setSelectedSurvivor(member)}
|
||||||
|
/>
|
||||||
|
<div className="col-start-2 text-sm font-semibold">{contact.name}</div>
|
||||||
|
<div className="col-start-2 opacity-90">{contact.contactUuid}</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DesignateSurvivorModal
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useApplication } from '@/Components/ApplicationProvider'
|
import { useApplication } from '@/Components/ApplicationProvider'
|
||||||
import { 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 ModalOverlay from '@/Components/Modal/ModalOverlay'
|
||||||
|
import DesignateSurvivorModal from './DesignateSurvivorModal'
|
||||||
|
|
||||||
export const VaultModalMembers = ({
|
export const VaultModalMembers = ({
|
||||||
members,
|
members,
|
||||||
@@ -27,9 +29,30 @@ export const VaultModalMembers = ({
|
|||||||
[application.vaultUsers, vault, onChange],
|
[application.vaultUsers, vault, onChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const vaultHasNoDesignatedSurvivor = vault.isSharedVaultListing() && !vault.sharing.designatedSurvivor
|
||||||
|
const [isDesignateSurvivorModalOpen, setIsDesignateSurvivorModalOpen] = useState(false)
|
||||||
|
const openDesignateSurvivorModal = () => setIsDesignateSurvivorModalOpen(true)
|
||||||
|
const closeDesignateSurvivorModal = () => setIsDesignateSurvivorModalOpen(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 text-lg">Vault Members</div>
|
<div className="mb-3 text-lg">Vault Members</div>
|
||||||
|
{vaultHasNoDesignatedSurvivor && members.length > 1 && isCurrentUserAdmin && (
|
||||||
|
<div className="bg-danger-faded mb-3 grid grid-cols-[auto,1fr] gap-x-[0.65rem] gap-y-0.5 overflow-hidden rounded p-2.5 text-danger">
|
||||||
|
<Icon type="warning" className="place-self-center" />
|
||||||
|
<div className="text-base font-semibold">No designated survivor</div>
|
||||||
|
<div className="col-start-2">
|
||||||
|
Vaults that have no designated survivor will be deleted when the owner account is deleted. In order to
|
||||||
|
ensure that no data is lost, please designate a survivor who will be transferred ownership of the vault.
|
||||||
|
</div>
|
||||||
|
<Button small className="col-start-2 mt-1.5" onClick={openDesignateSurvivorModal}>
|
||||||
|
Designate survivor
|
||||||
|
</Button>
|
||||||
|
<ModalOverlay isOpen={isDesignateSurvivorModalOpen} close={closeDesignateSurvivorModal}>
|
||||||
|
<DesignateSurvivorModal vault={vault} members={members} closeModal={closeDesignateSurvivorModal} />
|
||||||
|
</ModalOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const isMemberVaultOwner = application.vaultUsers.isVaultUserOwner(member)
|
const isMemberVaultOwner = application.vaultUsers.isVaultUserOwner(member)
|
||||||
@@ -55,6 +78,12 @@ export const VaultModalMembers = ({
|
|||||||
Untrusted
|
Untrusted
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{member.is_designated_survivor && (
|
||||||
|
<div className="flex items-center gap-1 rounded bg-info px-1 py-0.5 text-xs text-success-contrast">
|
||||||
|
<Icon type="security" size="small" />
|
||||||
|
Designated survivor
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 row-start-2">{permission}</div>
|
<div className="col-start-2 row-start-2">{permission}</div>
|
||||||
{isCurrentUserAdmin && !isMemberVaultOwner && (
|
{isCurrentUserAdmin && !isMemberVaultOwner && (
|
||||||
|
|||||||
@@ -116,21 +116,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mixin DimmedBackground($color, $opacity) {
|
@mixin DimmedBackground($color, $opacity) {
|
||||||
content: '';
|
position: relative;
|
||||||
width: 100%;
|
z-index: 1;
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
&::after {
|
||||||
left: 0;
|
content: '';
|
||||||
top: 0;
|
width: 100%;
|
||||||
background-color: $color;
|
height: 100%;
|
||||||
opacity: $opacity;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background-color: $color;
|
||||||
|
opacity: $opacity;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-warning-faded::after {
|
.bg-warning-faded {
|
||||||
@include DimmedBackground(var(--sn-stylekit-warning-color), 0.08);
|
@include DimmedBackground(var(--sn-stylekit-warning-color), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-info-faded::after {
|
.bg-danger-faded {
|
||||||
|
@include DimmedBackground(var(--sn-stylekit-danger-color), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-info-faded {
|
||||||
@include DimmedBackground(var(--sn-stylekit-info-color), 0.08);
|
@include DimmedBackground(var(--sn-stylekit-info-color), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user