feat: sharing permissions

This commit is contained in:
Daniel Dietzler
2026-04-01 13:10:22 +02:00
parent f88fdae048
commit 6f7821541a
84 changed files with 2382 additions and 347 deletions
@@ -26,12 +26,13 @@
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import { getSharedLink, hasPermissions, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
AssetVisibility,
SharingPermission,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
@@ -141,7 +142,7 @@
<ActionButton action={Actions.Edit} />
{#if isOwner}
{#if hasPermissions(asset, SharingPermission.AssetDelete)}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
@@ -159,7 +160,7 @@
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
{#if album && (hasPermissions(asset, SharingPermission.AssetShare) || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
@@ -187,7 +188,7 @@
{/if}
{#if !isLocked}
{#if isOwner}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
@@ -217,7 +218,7 @@
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
{#if isOwner}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
@@ -3,6 +3,7 @@
import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte';
import { timeToLoadTheMap } from '$lib/constants';
@@ -11,7 +12,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetMediaUrl, hasPermissions } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -20,6 +21,7 @@
AssetMediaSize,
getAllAlbums,
getAssetInfo,
SharingPermission,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
@@ -32,7 +34,6 @@
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props {
asset: AssetResponseDto;
@@ -42,6 +43,7 @@
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
const allowExifUpdate = $derived(hasPermissions(asset, SharingPermission.AssetUpdate, SharingPermission.ExifRead));
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -147,9 +149,9 @@
</section>
{/if}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
<DetailPanelDescription {asset} {allowExifUpdate} />
<DetailPanelRating {asset} {allowExifUpdate} />
<DetailPanelPeople {asset} {previousRoute} />
<div class="p-4">
{#if asset.exifInfo}
@@ -160,7 +162,7 @@
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} />
<DetailPanelDate {asset} {allowExifUpdate} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
@@ -168,7 +170,7 @@
<div>
<p class="flex place-items-center gap-2 break-all whitespace-pre-wrap">
{asset.originalFileName}
{#if isOwner}
{#if allowExifUpdate}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
@@ -271,7 +273,7 @@
</div>
{/if}
<DetailPanelLocation {isOwner} {asset} />
<DetailPanelLocation {allowExifUpdate} {asset} />
</div>
</section>
@@ -10,9 +10,10 @@
type Props = {
asset: AssetResponseDto;
allowExifUpdate: boolean;
};
const { asset }: Props = $props();
const { asset, allowExifUpdate }: Props = $props();
const timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
const dateTime = $derived(
@@ -20,13 +21,8 @@
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const handleChangeDate = async () => {
if (!isOwner) {
return;
}
await modalManager.show(AssetChangeDateModal, {
asset: toTimelineAsset(asset),
initialDate: dateTime,
@@ -40,8 +36,8 @@
type="button"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={handleChangeDate}
title={isOwner ? $t('edit_date') : ''}
class:hover:text-primary={isOwner}
title={allowExifUpdate ? $t('edit_date') : ''}
class:hover:text-primary={allowExifUpdate}
data-testid="detail-panel-edit-date-button"
>
<div class="flex gap-4">
@@ -68,13 +64,13 @@
</div>
</div>
{#if isOwner}
{#if allowExifUpdate}
<div class="p-1">
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !dateTime && isOwner}
{:else if !dateTime && allowExifUpdate}
<div class="flex place-items-start justify-between gap-4 py-4">
<div class="flex gap-4">
<Icon icon={mdiCalendar} size="24" />
@@ -8,10 +8,10 @@
interface Props {
asset: AssetResponseDto;
isOwner: boolean;
allowExifUpdate: boolean;
}
let { asset, isOwner }: Props = $props();
let { asset, allowExifUpdate }: Props = $props();
let description = $derived(asset.exifInfo?.description ?? '');
@@ -29,7 +29,7 @@
};
</script>
{#if isOwner}
{#if allowExifUpdate}
<section class="mt-10 px-4">
<Textarea
bind:value={description}
@@ -7,11 +7,11 @@
import { t } from 'svelte-i18n';
type Props = {
isOwner: boolean;
allowExifUpdate: boolean;
asset: AssetResponseDto;
};
let { isOwner, asset = $bindable() }: Props = $props();
let { allowExifUpdate, asset = $bindable() }: Props = $props();
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
@@ -34,9 +34,9 @@
<button
type="button"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner}
onclick={allowExifUpdate ? onAction : undefined}
title={allowExifUpdate ? $t('edit_location') : ''}
class:hover:text-primary={allowExifUpdate}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
@@ -58,13 +58,13 @@
</div>
</div>
{#if isOwner}
{#if allowExifUpdate}
<div>
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
{:else if !asset.exifInfo?.city && allowExifUpdate}
<button
type="button"
class="flex w-full place-items-start justify-between gap-4 rounded-lg py-4 text-start hover:text-primary"
@@ -5,8 +5,8 @@
import { Route } from '$lib/route';
import { faceManager } from '$lib/stores/face.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type AssetResponseDto } from '@immich/sdk';
import { getPeopleThumbnailUrl, hasPermissions } from '$lib/utils';
import { SharingPermission, type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon';
@@ -14,11 +14,10 @@
type Props = {
asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string;
};
const { asset, isOwner, previousRoute }: Props = $props();
const { asset, previousRoute }: Props = $props();
const people = $derived(Array.from(faceManager.people));
const visiblePeople = $derived(
@@ -56,7 +55,7 @@
);
</script>
{#if !authManager.isSharedLink && isOwner}
{#if !authManager.isSharedLink && hasPermissions(asset, SharingPermission.PersonRead)}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
@@ -8,10 +8,10 @@
interface Props {
asset: AssetResponseDto;
isOwner: boolean;
allowExifUpdate: boolean;
}
let { asset, isOwner }: Props = $props();
let { asset, allowExifUpdate }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
@@ -26,6 +26,10 @@
{#if !authManager.isSharedLink && authManager.authenticated && authManager.preferences.ratings.enabled}
<section class="px-4 pt-4">
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
<StarRating
{rating}
readOnly={!allowExifUpdate}
onRating={(rating) => handlePromiseError(handleChangeRating(rating))}
/>
</section>
{/if}
+1
View File
@@ -16,6 +16,7 @@
{ title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
{ title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
{ title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase },
{ title: 'Person grouping', value: ManualJobName.PersonGroupMerge },
].map(({ value, title }) => ({ id: value, label: title, value }));
let selectedJob: ComboBoxOption | undefined = $state(undefined);
@@ -36,7 +36,7 @@
try {
await mergePerson({
id: personToBeMergedInto.id,
mergePersonDto: { ids: [personToMerge.id] },
mergeFaceClusterDto: { ids: [personToMerge.id] },
});
toastManager.primary($t('merge_people_successfully'));
onClose([personToMerge, personToBeMergedInto]);
@@ -0,0 +1,78 @@
<script lang="ts">
import { getOwnAlbumUser, SharingPermission, updateOwnAlbumUser } from '@immich/sdk';
import { Checkbox, Field, FormModal, Heading, Stack, Switch, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { init } from 'svelte-i18n';
type Props = {
onClose: () => void;
albumId?: string;
partnerId?: string;
};
const { onClose, ...rest }: Props = $props();
let checkedPermissions = $state<SharingPermission[]>([]);
let viewInTimeline = $state<boolean>(false);
const onCheckedChange = (permission: SharingPermission, checked: boolean) => {
if (checked) {
checkedPermissions.push(permission);
} else {
checkedPermissions = checkedPermissions.filter((perm) => perm !== permission);
}
};
const onSubmit = async () => {
const permissions =
checkedPermissions.length === Object.values(SharingPermission).length - 1
? [SharingPermission.All]
: checkedPermissions;
if (rest.albumId) {
await updateOwnAlbumUser({
id: rest.albumId,
updateSharingOptionsDto: { permissions, inTimeline: viewInTimeline },
});
toastManager.success();
}
onClose();
};
onMount(async () => {
if (rest.albumId) {
const { permissions, inTimeline } = await getOwnAlbumUser({ id: rest.albumId });
checkedPermissions = permissions;
viewInTimeline = inTimeline;
}
});
</script>
<FormModal title="Sharing options" {onClose} {onSubmit}>
<Stack>
<Field label="View in timeline">
<Switch bind:checked={viewInTimeline} />
</Field>
<Heading>Permissions</Heading>
<Field label={SharingPermission.All}>
<Checkbox
id="permission-{SharingPermission.All}"
checked={checkedPermissions.length === Object.values(SharingPermission).length - 1}
onCheckedChange={(checked) =>
checked
? (checkedPermissions = Object.values(SharingPermission).filter(
(permission) => permission !== SharingPermission.All,
))
: (checkedPermissions = [])}
/>
</Field>
{#each Object.values(SharingPermission).filter((permission) => permission !== SharingPermission.All) as permission (permission)}
<Field label={permission}>
<Checkbox
id="permission-{permission}"
checked={checkedPermissions.includes(permission)}
onCheckedChange={(checked) => onCheckedChange(permission, checked)}
/>
</Field>
{/each}
</Stack>
</FormModal>
+10 -4
View File
@@ -5,6 +5,7 @@ import {
AssetVisibility,
getAssetInfo,
runAssetJobs,
SharingPermission,
updateAsset,
type AssetJobsDto,
type AssetResponseDto,
@@ -41,7 +42,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { getAssetMediaUrl, getSharedLink, hasPermissions, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -98,7 +99,12 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
$if: () => !!(authUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
$if: () =>
!!(
hasPermissions(asset, SharingPermission.AssetShare) &&
!asset.isTrashed &&
asset.visibility !== AssetVisibility.Locked
),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
@@ -119,7 +125,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const SharedLinkDownload: ActionItem = {
...Download,
$if: () => isOwner || !!sharedLink?.allowDownload,
$if: () => hasPermissions(asset, SharingPermission.AssetShare) || !!sharedLink?.allowDownload,
};
const PlayMotionPhoto: ActionItem = {
@@ -222,7 +228,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
icon: mdiTune,
$if: () =>
!sharedLink &&
isOwner &&
hasPermissions(asset, SharingPermission.AssetEdit) &&
asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
+15
View File
@@ -2,6 +2,7 @@ import {
AssetMediaSize,
AssetTypeEnum,
MemoryType,
SharingPermission,
finishOAuth,
getAssetOriginalPath,
getAssetPlaybackPath,
@@ -413,3 +414,17 @@ export const transformToTitleCase = (text: string) => {
}
return result.trim();
};
export const hasPermissions = (asset: AssetResponseDto, ...permissions: SharingPermission[]) => {
if (asset.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!asset.permissions.includes(permission)) {
return false;
}
}
return true;
};
@@ -73,11 +73,13 @@
mdiLink,
mdiPlus,
mdiPresentationPlay,
mdiShareVariant,
} from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import type { PageData } from './$types';
import SharingOptionsModal from '$lib/modals/SharingOptionsModal.svelte';
interface Props {
data: PageData;
@@ -405,9 +407,16 @@
{/if}
</button>
{#if isOwned}
<ActionButton action={Share} />
{/if}
<IconButton
shape="round"
aria-label="Sharing permissions"
color="secondary"
size="medium"
icon={mdiShareVariant}
onclick={() => modalManager.show(SharingOptionsModal, { albumId: album.id })}
/>
<ActionButton action={Share} />
</div>
{/if}
<AlbumDescription
+1 -1
View File
@@ -231,7 +231,7 @@
}
const personWithSimilarName = await findPeopleWithSimilarName(name, targetPerson.id);
if (personWithSimilarName) {
if (personWithSimilarName && personWithSimilarName.faceClusterId !== targetPerson.faceClusterId) {
personMerge1 = targetPerson;
personMerge2 = personWithSimilarName;
potentialMergePeople = people
@@ -68,7 +68,7 @@
try {
let results = await mergePerson({
id: person.id,
mergePersonDto: { ids: selectedPeople.map(({ id }) => id) },
mergeFaceClusterDto: { ids: selectedPeople.map(({ id }) => id) },
});
const mergedPerson = await getPerson({ id: person.id });
const count = results.filter(({ success }) => success).length;