feat: readonly album sharing (#8720)

* rename albums_shared_users_users to album_permissions and add readonly column

* disable synchronize on the original join table

* remove unnecessary FK names

* set readonly=true as default for new album shares

* separate and implement album READ and WRITE permission

* expose albumPermissions on the API, deprecate sharedUsers

* generate openapi

* create readonly view on frontend

* ??? move slideshow button out from ellipsis menu so that non-owners can have access too

* correct sharedUsers joins

* add album permission repository

* remove a log

* fix assetCount getting reset when adding users

* fix lint

* add set permission endpoint and UI

* sort users

* remove log

* Revert "??? move slideshow button out from ellipsis menu so that non-owners can have access too"

This reverts commit 1343bfa311.

* rename stuff

* fix db schema annotations

* sql generate

* change readonly default to follow migration

* fix deprecation notice

* change readonly boolean to role enum

* fix joincolumn as primary key

* rename albumUserRepository in album service

* clean up userId and albumId

* add write access to shared link

* fix existing tests

* switch to vitest

* format and fix tests on web

* add new test

* fix one e2e test

* rename new API field to albumUsers

* capitalize serverside enum

* remove unused ReadWrite type

* missed rename from previous commit

* rename to albumUsers in album entity as well

* remove outdated Equals calls

* unnecessary relation

* rename to updateUser in album service

* minor renamery

* move sorting to backend

* rename and separate ALBUM_WRITE as ADD_ASSET and REMOVE_ASSET

* fix tests

* fix "should migrate single moving picture" test failing on European system timezone

* generated changes after merge

* lint fix

* fix correct page to open after removing user from album

* fix e2e tests and some bugs

* rename updateAlbumUser rest endpoint

* add new e2e tests for updateAlbumUser endpoint

* small optimizations

* refactor album e2e test, add new album shared with viewer

* add new test to check if viewer can see the album

* add new e2e tests for readonly share

* failing test: User delete doesn't cascade to UserAlbum entity

* fix: handle deleted users

* use lodash for sort

* add role to addUsersToAlbum endpoint

* add UI for adding editors

* lint fixes

* change role back to editor as DB default

* fix server tests

* redesign user selection modal editor selector

* style tweaks

* fix type error

* Revert "style tweaks"

This reverts commit ab604f4c8f.

* Revert "redesign user selection modal editor selector"

This reverts commit e6f344856c.

* chore: cleanup and improve add user modal

* chore: open api

* small styling

---------

Co-authored-by: mgabor <>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
mgabor
2024-04-25 06:19:49 +02:00
committed by GitHub
parent 0b3373c552
commit 2943f93098
56 changed files with 1778 additions and 370 deletions

View File

@@ -10,6 +10,13 @@ import { Instrumentation } from 'src/utils/instrumentation';
import { setUnion } from 'src/utils/set';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => {
if (album) {
album.albumUsers = album.albumUsers.filter((albumUser) => albumUser.user && !albumUser.user.deletedAt);
}
return album;
};
@Instrumentation()
@Injectable()
export class AlbumRepository implements IAlbumRepository {
@@ -20,10 +27,10 @@ export class AlbumRepository implements IAlbumRepository {
) {}
@GenerateSql({ params: [DummyValue.UUID, {}] })
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
const relations: FindOptionsRelations<AlbumEntity> = {
owner: true,
sharedUsers: true,
albumUsers: { user: true },
assets: false,
sharedLinks: true,
};
@@ -40,33 +47,38 @@ export class AlbumRepository implements IAlbumRepository {
};
}
return this.repository.findOne({ where: { id }, relations, order });
const album = await this.repository.findOne({ where: { id }, relations, order });
return withoutDeletedUsers(album);
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({
async getByIds(ids: string[]): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
where: {
id: In(ids),
},
relations: {
owner: true,
sharedUsers: true,
albumUsers: { user: true },
},
});
return albums.map((album) => withoutDeletedUsers(album));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
return this.repository.find({
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
where: [
{ ownerId, assets: { id: assetId } },
{ sharedUsers: { id: ownerId }, assets: { id: assetId } },
{ albumUsers: { userId: ownerId }, assets: { id: assetId } },
],
relations: { owner: true, sharedUsers: true },
relations: { owner: true, albumUsers: { user: true } },
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@@ -127,40 +139,46 @@ export class AlbumRepository implements IAlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getOwned(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
async getOwned(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
relations: { albumUsers: { user: true }, sharedLinks: true, owner: true },
where: { ownerId },
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
/**
* Get albums shared with and shared by owner.
*/
@GenerateSql({ params: [DummyValue.UUID] })
getShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
async getShared(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
relations: { albumUsers: { user: true }, sharedLinks: true, owner: true },
where: [
{ sharedUsers: { id: ownerId } },
{ albumUsers: { userId: ownerId } },
{ sharedLinks: { userId: ownerId } },
{ ownerId, sharedUsers: { id: Not(IsNull()) } },
{ ownerId, albumUsers: { user: Not(IsNull()) } },
],
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
/**
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
getNotShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } },
async getNotShared(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
relations: { albumUsers: true, sharedLinks: true, owner: true },
where: { ownerId, albumUsers: { user: IsNull() }, sharedLinks: { id: IsNull() } },
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
async restoreAll(userId: string): Promise<void> {
@@ -282,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository {
where: { id },
relations: {
owner: true,
sharedUsers: true,
albumUsers: { user: true },
sharedLinks: true,
assets: true,
},