mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00:00
refactor(web): access controls
This commit is contained in:
parent
7fbf50a75e
commit
0aa606c893
6 changed files with 236 additions and 18 deletions
|
@ -9,7 +9,6 @@ describe('AssetViewerNavBar component', () => {
|
||||||
const additionalProps = {
|
const additionalProps = {
|
||||||
showCopyButton: false,
|
showCopyButton: false,
|
||||||
showZoomButton: false,
|
showZoomButton: false,
|
||||||
showDetailButton: false,
|
|
||||||
showDownloadButton: false,
|
showDownloadButton: false,
|
||||||
showMotionPlayButton: false,
|
showMotionPlayButton: false,
|
||||||
showShareButton: false,
|
showShareButton: false,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { access } from '$lib/stores/access.store';
|
||||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
||||||
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
|
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
|
||||||
|
@ -17,7 +18,7 @@
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
import { getAssetJobName } from '$lib/utils';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
|
@ -44,7 +45,6 @@
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
export let album: AlbumResponseDto | null = null;
|
export let album: AlbumResponseDto | null = null;
|
||||||
export let stack: StackResponseDto | null = null;
|
export let stack: StackResponseDto | null = null;
|
||||||
export let showDetailButton: boolean;
|
|
||||||
export let showSlideshow = false;
|
export let showSlideshow = false;
|
||||||
export let onZoomImage: () => void;
|
export let onZoomImage: () => void;
|
||||||
export let onCopyImage: () => void;
|
export let onCopyImage: () => void;
|
||||||
|
@ -55,10 +55,7 @@
|
||||||
// export let showEditorHandler: () => void;
|
// export let showEditorHandler: () => void;
|
||||||
export let onClose: () => void;
|
export let onClose: () => void;
|
||||||
|
|
||||||
const sharedLink = getSharedLink();
|
|
||||||
|
|
||||||
$: isOwner = $user && asset.ownerId === $user?.id;
|
$: isOwner = $user && asset.ownerId === $user?.id;
|
||||||
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
|
|
||||||
// $: showEditorButton =
|
// $: showEditorButton =
|
||||||
// isOwner &&
|
// isOwner &&
|
||||||
// asset.type === AssetTypeEnum.Image &&
|
// asset.type === AssetTypeEnum.Image &&
|
||||||
|
@ -80,7 +77,7 @@
|
||||||
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
|
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
|
||||||
data-testid="asset-viewer-navbar-actions"
|
data-testid="asset-viewer-navbar-actions"
|
||||||
>
|
>
|
||||||
{#if !asset.isTrashed && $user}
|
{#if $access({ asset, access: 'asset.share' })}
|
||||||
<ShareAction {asset} />
|
<ShareAction {asset} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.isOffline}
|
{#if asset.isOffline}
|
||||||
|
@ -102,15 +99,16 @@
|
||||||
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
|
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isOwner && showDownloadButton}
|
<!-- owner already has download in the overflow menu -->
|
||||||
|
{#if !isOwner && !asset.isOffline && $access({ asset, access: 'asset.download' })}
|
||||||
<DownloadAction {asset} />
|
<DownloadAction {asset} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDetailButton}
|
{#if asset.hasMetadata}
|
||||||
<ShowDetailAction {onShowDetail} />
|
<ShowDetailAction {onShowDetail} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if $access({ asset, access: 'asset.favorite' })}
|
||||||
<FavoriteAction {asset} {onAction} />
|
<FavoriteAction {asset} {onAction} />
|
||||||
{/if}
|
{/if}
|
||||||
<!-- {#if showEditorButton}
|
<!-- {#if showEditorButton}
|
||||||
|
@ -124,16 +122,18 @@
|
||||||
{/if} -->
|
{/if} -->
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<DeleteAction {asset} {onAction} />
|
{#if !asset.isTrashed && $access({ asset, access: 'asset.delete' })}
|
||||||
|
<DeleteAction {asset} {onAction} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
||||||
{#if showSlideshow}
|
{#if showSlideshow}
|
||||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showDownloadButton}
|
{#if !asset.isOffline && $access({ asset, access: 'asset.download' })}
|
||||||
<DownloadAction {asset} menuItem />
|
<DownloadAction {asset} menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.isTrashed}
|
{#if asset.isTrashed && $access({ asset, access: 'asset.delete' })}
|
||||||
<RestoreAction {asset} {onAction} />
|
<RestoreAction {asset} {onAction} />
|
||||||
{:else}
|
{:else}
|
||||||
<AddToAlbumAction {asset} {onAction} />
|
<AddToAlbumAction {asset} {onAction} />
|
||||||
|
@ -141,10 +141,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
{#if stack}
|
{#if stack && $access({ asset, access: 'asset.stack' })}
|
||||||
<UnstackAction {stack} {onAction} />
|
<UnstackAction {stack} {onAction} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if album}
|
|
||||||
|
{#if album && $access({ album, access: 'album.update' })}
|
||||||
<SetAlbumCoverAction {asset} {album} />
|
<SetAlbumCoverAction {asset} {album} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.type === AssetTypeEnum.Image}
|
{#if asset.type === AssetTypeEnum.Image}
|
||||||
|
|
|
@ -77,7 +77,6 @@
|
||||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||||
let shouldPlayMotionPhoto = false;
|
let shouldPlayMotionPhoto = false;
|
||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
let enableDetailPanel = asset.hasMetadata;
|
|
||||||
let slideshowStateUnsubscribe: () => void;
|
let slideshowStateUnsubscribe: () => void;
|
||||||
let shuffleSlideshowUnsubscribe: () => void;
|
let shuffleSlideshowUnsubscribe: () => void;
|
||||||
let previewStackedAsset: AssetResponseDto | undefined;
|
let previewStackedAsset: AssetResponseDto | undefined;
|
||||||
|
@ -415,7 +414,6 @@
|
||||||
{asset}
|
{asset}
|
||||||
{album}
|
{album}
|
||||||
{stack}
|
{stack}
|
||||||
showDetailButton={enableDetailPanel}
|
|
||||||
showSlideshow={!!assetStore}
|
showSlideshow={!!assetStore}
|
||||||
onZoomImage={zoomToggle}
|
onZoomImage={zoomToggle}
|
||||||
onCopyImage={copyImage}
|
onCopyImage={copyImage}
|
||||||
|
@ -531,7 +529,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
|
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
|
|
112
web/src/lib/stores/access.store.spec.ts
Normal file
112
web/src/lib/stores/access.store.spec.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { hasAccess } from '$lib/stores/access.store';
|
||||||
|
import { albumFactory } from '@test-data/factories/album-factory';
|
||||||
|
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||||
|
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||||
|
import { userAdminFactory, userFactory } from '@test-data/factories/user-factory';
|
||||||
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
|
const user = userAdminFactory.build({ id: 'user-id' });
|
||||||
|
|
||||||
|
describe('AccessStore', () => {
|
||||||
|
describe('hasAccess', () => {
|
||||||
|
describe('asset.share', () => {
|
||||||
|
const access = 'asset.share';
|
||||||
|
|
||||||
|
it('should return true for owned assets', () => {
|
||||||
|
const asset = assetFactory.build({ ownerId: user.id });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for not owned assets', () => {
|
||||||
|
const asset = assetFactory.build({ isOffline: false });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for trashed assets', () => {
|
||||||
|
const asset = assetFactory.build({ ownerId: user.id, isTrashed: true });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('asset.download', () => {
|
||||||
|
const access = 'asset.download';
|
||||||
|
|
||||||
|
it('should return true for owned assets', () => {
|
||||||
|
const asset = assetFactory.build({ ownerId: user.id, isOffline: false });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for not owned assets', () => {
|
||||||
|
const asset = assetFactory.build({ isOffline: false });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for partner sharing', () => {
|
||||||
|
const asset = assetFactory.build({ ownerId: user.id, isOffline: false });
|
||||||
|
const partner = userFactory.build();
|
||||||
|
expect(hasAccess({ access, asset }, { user, partners: [partner] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for shared links', () => {
|
||||||
|
const asset = assetFactory.build({ isOffline: false });
|
||||||
|
const sharedLink = sharedLinkFactory.build({ allowDownload: false });
|
||||||
|
expect(hasAccess({ access, asset }, { sharedLink })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for shared links with download enabled', () => {
|
||||||
|
const asset = assetFactory.build({ isOffline: false });
|
||||||
|
const sharedLink = sharedLinkFactory.build({ allowDownload: true });
|
||||||
|
expect(hasAccess({ access, asset }, { sharedLink })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('asset.unstack', () => {
|
||||||
|
const access = 'asset.unstack';
|
||||||
|
|
||||||
|
it('should return true for owned assets', () => {
|
||||||
|
const asset = assetFactory.build({ ownerId: user.id });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non owned assets', () => {
|
||||||
|
const asset = assetFactory.build();
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for partner sharing', () => {
|
||||||
|
const partner = userFactory.build({ id: 'partner-id' });
|
||||||
|
const asset = assetFactory.build({ ownerId: partner.id });
|
||||||
|
expect(hasAccess({ access, asset }, { user, partners: [partner] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('asset.favorite', () => {
|
||||||
|
const access = 'asset.favorite';
|
||||||
|
|
||||||
|
it('should return true for owned assets', () => {
|
||||||
|
const asset = assetFactory.build({ ownerId: user.id });
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non owned assets', () => {
|
||||||
|
const asset = assetFactory.build();
|
||||||
|
expect(hasAccess({ access, asset }, { user })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for partner sharing', () => {
|
||||||
|
const partner = userFactory.build({ id: 'partner-id' });
|
||||||
|
const asset = assetFactory.build({ ownerId: partner.id });
|
||||||
|
expect(hasAccess({ access, asset }, { user, partners: [partner] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('album.update', () => {
|
||||||
|
const access = 'album.update';
|
||||||
|
|
||||||
|
it('should return true for owned albums', () => {
|
||||||
|
const album = albumFactory.build({ ownerId: user.id });
|
||||||
|
expect(hasAccess({ access, album }, { user })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
104
web/src/lib/stores/access.store.ts
Normal file
104
web/src/lib/stores/access.store.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import { user as userStore } from '$lib/stores/user.store';
|
||||||
|
import { getSharedLink } from '$lib/utils';
|
||||||
|
import {
|
||||||
|
getPartners,
|
||||||
|
PartnerDirection,
|
||||||
|
Permission,
|
||||||
|
type AlbumResponseDto,
|
||||||
|
type AssetResponseDto,
|
||||||
|
type PartnerResponseDto,
|
||||||
|
type SharedLinkResponseDto,
|
||||||
|
type UserAdminResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { derived, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
type AssetPermissions =
|
||||||
|
| 'asset.delete'
|
||||||
|
| 'asset.share'
|
||||||
|
| 'asset.download'
|
||||||
|
| 'asset.upload'
|
||||||
|
| 'asset.favorite'
|
||||||
|
| 'asset.stack'
|
||||||
|
| 'asset.unstack';
|
||||||
|
|
||||||
|
type AlbumPermissions = 'album.update';
|
||||||
|
|
||||||
|
type AssetAccessRequest = { asset: AssetResponseDto; access: AssetPermissions };
|
||||||
|
type AlbumAccessRequest = { album: AlbumResponseDto; access: AlbumPermissions };
|
||||||
|
type AccessRequest = AssetAccessRequest | AlbumAccessRequest;
|
||||||
|
|
||||||
|
type AccessData = {
|
||||||
|
user?: UserAdminResponseDto;
|
||||||
|
partners?: PartnerResponseDto[];
|
||||||
|
sharedLink?: SharedLinkResponseDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAssetRequest = (request: AccessRequest): request is AssetAccessRequest => 'asset' in request;
|
||||||
|
const isAlbumRequest = (request: AccessRequest): request is AlbumAccessRequest => 'album' in request;
|
||||||
|
|
||||||
|
export const hasAccess = (request: AccessRequest, data: AccessData) => {
|
||||||
|
return hasUserAccess(request, data) || hasSharedLinkAccess(request, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserAccess = (request: AccessRequest, { user, partners }: AccessData) => {
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerIds = new Set((partners || []).map((partner) => partner.id));
|
||||||
|
|
||||||
|
if (isAssetRequest(request)) {
|
||||||
|
const { asset, access } = request;
|
||||||
|
if (asset.ownerId === user.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((['asset.download'] as AssetPermissions[]).includes(access) && partnerIds.has(asset.ownerId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAlbumRequest(request)) {
|
||||||
|
const { album } = request;
|
||||||
|
if (album.ownerId === user.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSharedLinkAccess = (request: AccessRequest, { sharedLink }: AccessData) => {
|
||||||
|
if (!sharedLink) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (request.access) {
|
||||||
|
case Permission.AssetUpload: {
|
||||||
|
return sharedLink.allowUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Permission.AssetDownload: {
|
||||||
|
return sharedLink.allowDownload;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const partnerStore = writable<PartnerResponseDto[]>([]);
|
||||||
|
export const access = derived([userStore, partnerStore], ([user, partners]) => {
|
||||||
|
const data = { user, partners, sharedLink: getSharedLink() };
|
||||||
|
return (request: AccessRequest) => hasAccess(request, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const partners = await getPartners({ direction: PartnerDirection.SharedWith });
|
||||||
|
partnerStore.set(partners);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accessManager = {
|
||||||
|
init,
|
||||||
|
};
|
|
@ -1,4 +1,5 @@
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { accessManager } from '$lib/stores/access.store';
|
||||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||||
import { serverInfo } from '$lib/stores/server-info.store';
|
import { serverInfo } from '$lib/stores/server-info.store';
|
||||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||||
|
@ -24,6 +25,9 @@ export const loadUser = async () => {
|
||||||
user$.set(user);
|
user$.set(user);
|
||||||
preferences$.set(preferences);
|
preferences$.set(preferences);
|
||||||
|
|
||||||
|
// TODO invert (emit an event that accessManager listens to)
|
||||||
|
await accessManager.init();
|
||||||
|
|
||||||
// Check for license status
|
// Check for license status
|
||||||
if (serverInfo.licensed || user.license?.activatedAt) {
|
if (serverInfo.licensed || user.license?.activatedAt) {
|
||||||
purchaseStore.setPurchaseStatus(true);
|
purchaseStore.setPurchaseStatus(true);
|
||||||
|
|
Loading…
Reference in a new issue