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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1778 additions and 370 deletions

View file

@ -13,25 +13,32 @@ part of openapi.api;
class AddUsersDto {
/// Returns a new [AddUsersDto] instance.
AddUsersDto({
this.albumUsers = const [],
this.sharedUserIds = const [],
});
List<AlbumUserAddDto> albumUsers;
/// Deprecated in favor of albumUsers
List<String> sharedUserIds;
@override
bool operator ==(Object other) => identical(this, other) || other is AddUsersDto &&
_deepEquality.equals(other.albumUsers, albumUsers) &&
_deepEquality.equals(other.sharedUserIds, sharedUserIds);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumUsers.hashCode) +
(sharedUserIds.hashCode);
@override
String toString() => 'AddUsersDto[sharedUserIds=$sharedUserIds]';
String toString() => 'AddUsersDto[albumUsers=$albumUsers, sharedUserIds=$sharedUserIds]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumUsers'] = this.albumUsers;
json[r'sharedUserIds'] = this.sharedUserIds;
return json;
}
@ -44,6 +51,7 @@ class AddUsersDto {
final json = value.cast<String, dynamic>();
return AddUsersDto(
albumUsers: AlbumUserAddDto.listFromJson(json[r'albumUsers']),
sharedUserIds: json[r'sharedUserIds'] is Iterable
? (json[r'sharedUserIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
@ -94,7 +102,7 @@ class AddUsersDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'sharedUserIds',
'albumUsers',
};
}

View file

@ -15,6 +15,7 @@ class AlbumResponseDto {
AlbumResponseDto({
required this.albumName,
required this.albumThumbnailAssetId,
this.albumUsers = const [],
required this.assetCount,
this.assets = const [],
required this.createdAt,
@ -37,6 +38,8 @@ class AlbumResponseDto {
String? albumThumbnailAssetId;
List<AlbumUserResponseDto> albumUsers;
int assetCount;
List<AssetResponseDto> assets;
@ -81,6 +84,7 @@ class AlbumResponseDto {
bool shared;
/// Deprecated in favor of albumUsers
List<UserResponseDto> sharedUsers;
///
@ -97,6 +101,7 @@ class AlbumResponseDto {
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
other.albumName == albumName &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
_deepEquality.equals(other.albumUsers, albumUsers) &&
other.assetCount == assetCount &&
_deepEquality.equals(other.assets, assets) &&
other.createdAt == createdAt &&
@ -119,6 +124,7 @@ class AlbumResponseDto {
// ignore: unnecessary_parenthesis
(albumName.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(albumUsers.hashCode) +
(assetCount.hashCode) +
(assets.hashCode) +
(createdAt.hashCode) +
@ -137,7 +143,7 @@ class AlbumResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -147,6 +153,7 @@ class AlbumResponseDto {
} else {
// json[r'albumThumbnailAssetId'] = null;
}
json[r'albumUsers'] = this.albumUsers;
json[r'assetCount'] = this.assetCount;
json[r'assets'] = this.assets;
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
@ -192,6 +199,7 @@ class AlbumResponseDto {
return AlbumResponseDto(
albumName: mapValueOfType<String>(json, r'albumName')!,
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
albumUsers: AlbumUserResponseDto.listFromJson(json[r'albumUsers']),
assetCount: mapValueOfType<int>(json, r'assetCount')!,
assets: AssetResponseDto.listFromJson(json[r'assets']),
createdAt: mapDateTime(json, r'createdAt', r'')!,
@ -257,6 +265,7 @@ class AlbumResponseDto {
static const requiredKeys = <String>{
'albumName',
'albumThumbnailAssetId',
'albumUsers',
'assetCount',
'assets',
'createdAt',

View file

@ -0,0 +1,115 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AlbumUserAddDto {
/// Returns a new [AlbumUserAddDto] instance.
AlbumUserAddDto({
this.role,
required this.userId,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AlbumUserRole? role;
String userId;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumUserAddDto &&
other.role == role &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(role == null ? 0 : role!.hashCode) +
(userId.hashCode);
@override
String toString() => 'AlbumUserAddDto[role=$role, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.role != null) {
json[r'role'] = this.role;
} else {
// json[r'role'] = null;
}
json[r'userId'] = this.userId;
return json;
}
/// Returns a new [AlbumUserAddDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumUserAddDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AlbumUserAddDto(
role: AlbumUserRole.fromJson(json[r'role']),
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<AlbumUserAddDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumUserAddDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AlbumUserAddDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AlbumUserAddDto> mapFromJson(dynamic json) {
final map = <String, AlbumUserAddDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumUserAddDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AlbumUserAddDto-objects as value to a dart map
static Map<String, List<AlbumUserAddDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumUserAddDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AlbumUserAddDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'userId',
};
}

View file

@ -0,0 +1,106 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AlbumUserResponseDto {
/// Returns a new [AlbumUserResponseDto] instance.
AlbumUserResponseDto({
required this.role,
required this.user,
});
AlbumUserRole role;
UserResponseDto user;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumUserResponseDto &&
other.role == role &&
other.user == user;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(role.hashCode) +
(user.hashCode);
@override
String toString() => 'AlbumUserResponseDto[role=$role, user=$user]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'role'] = this.role;
json[r'user'] = this.user;
return json;
}
/// Returns a new [AlbumUserResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AlbumUserResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AlbumUserResponseDto(
role: AlbumUserRole.fromJson(json[r'role'])!,
user: UserResponseDto.fromJson(json[r'user'])!,
);
}
return null;
}
static List<AlbumUserResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumUserResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AlbumUserResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AlbumUserResponseDto> mapFromJson(dynamic json) {
final map = <String, AlbumUserResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumUserResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AlbumUserResponseDto-objects as value to a dart map
static Map<String, List<AlbumUserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumUserResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AlbumUserResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'role',
'user',
};
}

View file

@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AlbumUserRole {
/// Instantiate a new enum with the provided [value].
const AlbumUserRole._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const editor = AlbumUserRole._(r'editor');
static const viewer = AlbumUserRole._(r'viewer');
/// List of all possible values in this [enum][AlbumUserRole].
static const values = <AlbumUserRole>[
editor,
viewer,
];
static AlbumUserRole? fromJson(dynamic value) => AlbumUserRoleTypeTransformer().decode(value);
static List<AlbumUserRole> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumUserRole>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AlbumUserRole.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AlbumUserRole] to String,
/// and [decode] dynamic data back to [AlbumUserRole].
class AlbumUserRoleTypeTransformer {
factory AlbumUserRoleTypeTransformer() => _instance ??= const AlbumUserRoleTypeTransformer._();
const AlbumUserRoleTypeTransformer._();
String encode(AlbumUserRole data) => data.value;
/// Decodes a [dynamic value][data] to a AlbumUserRole.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AlbumUserRole? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'editor': return AlbumUserRole.editor;
case r'viewer': return AlbumUserRole.viewer;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AlbumUserRoleTypeTransformer] instance.
static AlbumUserRoleTypeTransformer? _instance;
}

View file

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UpdateAlbumUserDto {
/// Returns a new [UpdateAlbumUserDto] instance.
UpdateAlbumUserDto({
required this.role,
});
AlbumUserRole role;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserDto &&
other.role == role;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(role.hashCode);
@override
String toString() => 'UpdateAlbumUserDto[role=$role]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'role'] = this.role;
return json;
}
/// Returns a new [UpdateAlbumUserDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateAlbumUserDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateAlbumUserDto(
role: AlbumUserRole.fromJson(json[r'role'])!,
);
}
return null;
}
static List<UpdateAlbumUserDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateAlbumUserDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateAlbumUserDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateAlbumUserDto> mapFromJson(dynamic json) {
final map = <String, UpdateAlbumUserDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateAlbumUserDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateAlbumUserDto-objects as value to a dart map
static Map<String, List<UpdateAlbumUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateAlbumUserDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateAlbumUserDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'role',
};
}