1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-21 07:26:25 +02:00

feat (server, web): Share with partner ()

* feat(server, web): implement share with partner

* chore: regenerate api

* chore: regenerate api

* Pass userId to getAssetCountByTimeBucket and getAssetByTimeBucket

* chore: regenerate api

* Use AssetGrid to view partner's assets

* Remove disableNavBarActions flag

* Check access to buckets

* Apply suggestions from code review

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Remove exception rethrowing

* Simplify partner access check

* Create new PartnerController

* chore api:generate

* Use partnerApi

* Remove id from PartnerResponseDto

* Refactor PartnerEntity

* Rename args

* Remove duplicate code in getAll

* Create composite primary keys for partners table

* Move asset access check into PartnerCore

* Remove redundant getUserAssets call

* Remove unused getUserAssets method

* chore: regenerate api

* Simplify getAll

* Replace ?? with ||

* Simplify PartnerRepository.create

* Introduce PartnerIds interface

* Replace two database migrations with one

* Simplify getAll

* Change PartnerResponseDto to include UserResponseDto

* Move partner sharing endpoints to PartnerController

* Rename ShareController to SharedLinkController

* chore: regenerate api after rebase

* refactor: shared link remove return type

* refactor: return user response dto

* chore: regenerate open api

* refactor: partner getAll

* refactor: partner settings event typing

* chore: remove unused code

* refactor: add partners modal trigger

* refactor: update url for viewing partner photos

* feat: update partner sharing title

* refactor: rename service method names

* refactor: http exception logic to service, PartnerIds interface

* chore: regenerate open api

* test: coverage for domain code

* fix: addPartner => createPartner

* fix: missed rename

* refactor: more code cleanup

* chore: alphabetize settings order

* feat: stop sharing confirmation modal

* Enhance contrast of the email in dark mode

* Replace button with CircleIconButton

* Fix linter warning

* Fix date types for PartnerEntity

* Fix PartnerEntity creation

* Reset assetStore state

* Change layout of the partner's assets page

* Add bulk download action for partner's assets

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Sergey Kondrikov 2023-05-15 20:30:53 +03:00 committed by GitHub
parent 4524aa0d06
commit 7f2fa23179
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1669 additions and 92 deletions

View file

@ -60,6 +60,7 @@ doc/OAuthApi.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/QueueStatusDto.md
doc/RemoveAssetsDto.md
doc/SearchAlbumResponseDto.md
@ -111,6 +112,7 @@ lib/api/asset_api.dart
lib/api/authentication_api.dart
lib/api/job_api.dart
lib/api/o_auth_api.dart
lib/api/partner_api.dart
lib/api/search_api.dart
lib/api/server_info_api.dart
lib/api/share_api.dart
@ -271,6 +273,7 @@ test/o_auth_api_test.dart
test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/partner_api_test.dart
test/queue_status_dto_test.dart
test/remove_assets_dto_test.dart
test/search_album_response_dto_test.dart

View file

@ -129,6 +129,9 @@ Class | Method | HTTP request | Description
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |
*OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect |
*OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink |
*PartnerApi* | [**createPartner**](doc//PartnerApi.md#createpartner) | **POST** /partner/{id} |
*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner |
*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |

View file

@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**timeBucket** | **List<String>** | | [default to const []]
**userId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**timeGroup** | [**TimeGroupEnum**](TimeGroupEnum.md) | |
**userId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

180
mobile/openapi/doc/PartnerApi.md generated Normal file
View file

@ -0,0 +1,180 @@
# openapi.api.PartnerApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**createPartner**](PartnerApi.md#createpartner) | **POST** /partner/{id} |
[**getPartners**](PartnerApi.md#getpartners) | **GET** /partner |
[**removePartner**](PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
# **createPartner**
> UserResponseDto createPartner(id)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PartnerApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.createPartner(id);
print(result);
} catch (e) {
print('Exception when calling PartnerApi->createPartner: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
[**UserResponseDto**](UserResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getPartners**
> List<UserResponseDto> getPartners(direction)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PartnerApi();
final direction = direction_example; // String |
try {
final result = api_instance.getPartners(direction);
print(result);
} catch (e) {
print('Exception when calling PartnerApi->getPartners: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**direction** | **String**| |
### Return type
[**List<UserResponseDto>**](UserResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **removePartner**
> removePartner(id)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PartnerApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
api_instance.removePartner(id);
} catch (e) {
print('Exception when calling PartnerApi->removePartner: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View file

@ -34,6 +34,7 @@ part 'api/asset_api.dart';
part 'api/authentication_api.dart';
part 'api/job_api.dart';
part 'api/o_auth_api.dart';
part 'api/partner_api.dart';
part 'api/search_api.dart';
part 'api/server_info_api.dart';
part 'api/share_api.dart';

158
mobile/openapi/lib/api/partner_api.dart generated Normal file
View file

@ -0,0 +1,158 @@
//
// 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 PartnerApi {
PartnerApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'POST /partner/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> createPartnerWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/partner/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<UserResponseDto?> createPartner(String id,) async {
final response = await createPartnerWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /partner' operation and returns the [Response].
/// Parameters:
///
/// * [String] direction (required):
Future<Response> getPartnersWithHttpInfo(String direction,) async {
// ignore: prefer_const_declarations
final path = r'/partner';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'direction', direction));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] direction (required):
Future<List<UserResponseDto>?> getPartners(String direction,) async {
final response = await getPartnersWithHttpInfo(direction,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<UserResponseDto>') as List)
.cast<UserResponseDto>()
.toList();
}
return null;
}
/// Performs an HTTP 'DELETE /partner/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> removePartnerWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/partner/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> removePartner(String id,) async {
final response = await removePartnerWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View file

@ -14,25 +14,41 @@ class GetAssetByTimeBucketDto {
/// Returns a new [GetAssetByTimeBucketDto] instance.
GetAssetByTimeBucketDto({
this.timeBucket = const [],
this.userId,
});
List<String> timeBucket;
///
/// 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.
///
String? userId;
@override
bool operator ==(Object other) => identical(this, other) || other is GetAssetByTimeBucketDto &&
other.timeBucket == timeBucket;
other.timeBucket == timeBucket &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(timeBucket.hashCode);
(timeBucket.hashCode) +
(userId == null ? 0 : userId!.hashCode);
@override
String toString() => 'GetAssetByTimeBucketDto[timeBucket=$timeBucket]';
String toString() => 'GetAssetByTimeBucketDto[timeBucket=$timeBucket, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'timeBucket'] = this.timeBucket;
if (this.userId != null) {
json[r'userId'] = this.userId;
} else {
// json[r'userId'] = null;
}
return json;
}
@ -58,6 +74,7 @@ class GetAssetByTimeBucketDto {
timeBucket: json[r'timeBucket'] is Iterable
? (json[r'timeBucket'] as Iterable).cast<String>().toList(growable: false)
: const [],
userId: mapValueOfType<String>(json, r'userId'),
);
}
return null;

View file

@ -14,25 +14,41 @@ class GetAssetCountByTimeBucketDto {
/// Returns a new [GetAssetCountByTimeBucketDto] instance.
GetAssetCountByTimeBucketDto({
required this.timeGroup,
this.userId,
});
TimeGroupEnum timeGroup;
///
/// 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.
///
String? userId;
@override
bool operator ==(Object other) => identical(this, other) || other is GetAssetCountByTimeBucketDto &&
other.timeGroup == timeGroup;
other.timeGroup == timeGroup &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(timeGroup.hashCode);
(timeGroup.hashCode) +
(userId == null ? 0 : userId!.hashCode);
@override
String toString() => 'GetAssetCountByTimeBucketDto[timeGroup=$timeGroup]';
String toString() => 'GetAssetCountByTimeBucketDto[timeGroup=$timeGroup, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'timeGroup'] = this.timeGroup;
if (this.userId != null) {
json[r'userId'] = this.userId;
} else {
// json[r'userId'] = null;
}
return json;
}
@ -56,6 +72,7 @@ class GetAssetCountByTimeBucketDto {
return GetAssetCountByTimeBucketDto(
timeGroup: TimeGroupEnum.fromJson(json[r'timeGroup'])!,
userId: mapValueOfType<String>(json, r'userId'),
);
}
return null;

View file

@ -21,6 +21,11 @@ void main() {
// TODO
});
// String userId
test('to test the property `userId`', () async {
// TODO
});
});

View file

@ -21,6 +21,11 @@ void main() {
// TODO
});
// String userId
test('to test the property `userId`', () async {
// TODO
});
});

View file

@ -0,0 +1,36 @@
//
// 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
import 'package:openapi/api.dart';
import 'package:test/test.dart';
/// tests for PartnerApi
void main() {
// final instance = PartnerApi();
group('tests for PartnerApi', () {
//Future<UserResponseDto> createPartner(String id) async
test('test createPartner', () async {
// TODO
});
//Future<List<UserResponseDto>> getPartners(String direction) async
test('test getPartners', () async {
// TODO
});
//Future removePartner(String id) async
test('test removePartner', () async {
// TODO
});
});
}

View file

@ -8,7 +8,14 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain';
import {
ICryptoRepository,
IJobRepository,
IPartnerRepository,
ISharedLinkRepository,
IStorageRepository,
JobName,
} from '@app/domain';
import {
assetEntityStub,
authStub,
@ -126,6 +133,7 @@ describe('AssetService', () => {
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let partnerRepositoryMock: jest.Mocked<IPartnerRepository>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
@ -178,6 +186,7 @@ describe('AssetService', () => {
jobMock,
cryptoMock,
storageMock,
partnerRepositoryMock,
);
when(assetRepositoryMock.get)

View file

@ -32,6 +32,7 @@ import {
mapAssetWithoutExif,
MapMarkerResponseDto,
mapAssetMapMarker,
PartnerCore,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
@ -56,6 +57,7 @@ import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
import { ShareCore } from '@app/domain';
import { IPartnerRepository } from '@app/domain';
import { ISharedLinkRepository } from '@app/domain';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
@ -76,6 +78,7 @@ export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: ShareCore;
private assetCore: AssetCore;
private partnerCore: PartnerCore;
constructor(
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@ -87,9 +90,11 @@ export class AssetService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
this.partnerCore = new PartnerCore(partnerRepository);
}
public async uploadFile(
@ -154,7 +159,14 @@ export class AssetService {
authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
): Promise<AssetResponseDto[]> {
const assets = await this._assetRepository.getAssetByTimeBucket(authUser.id, getAssetByTimeBucketDto);
if (getAssetByTimeBucketDto.userId) {
await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId);
}
const assets = await this._assetRepository.getAssetByTimeBucket(
getAssetByTimeBucketDto.userId || authUser.id,
getAssetByTimeBucketDto,
);
return assets.map((asset) => mapAsset(asset));
}
@ -458,8 +470,12 @@ export class AssetService {
authUser: AuthUserDto,
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
if (getAssetCountByTimeBucketDto.userId !== undefined) {
await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId);
}
const result = await this._assetRepository.getAssetCountByTimeBucket(
authUser.id,
getAssetCountByTimeBucketDto.userId || authUser.id,
getAssetCountByTimeBucketDto.timeGroup,
);
@ -492,6 +508,12 @@ export class AssetService {
continue;
}
// Step 3: Check if any partner owns the asset
const canAccess = await this.partnerCore.hasAssetAccess(assetId, authUser.id);
if (canAccess) {
continue;
}
// Avoid additional checks if ownership is required
if (!mustBeOwner) {
// Step 2: Check if asset is part of an album shared with me
@ -505,6 +527,13 @@ export class AssetService {
}
}
private async checkUserAccess(authUser: AuthUserDto, userId: string) {
// Check if userId shares assets with authUser
if (!(await this.partnerCore.get({ sharedById: userId, sharedWithId: authUser.id }))) {
throw new ForbiddenException();
}
}
checkDownloadAccess(authUser: AuthUserDto) {
this.shareCore.checkDownloadAccess(authUser);
}

View file

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
export class GetAssetByTimeBucketDto {
@IsNotEmpty()
@ -10,4 +10,9 @@ export class GetAssetByTimeBucketDto {
example: ['2015-06-01T00:00:00.000Z', '2016-02-01T00:00:00.000Z', '2016-03-01T00:00:00.000Z'],
})
timeBucket!: string[];
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
}

View file

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
export enum TimeGroupEnum {
Day = 'day',
@ -14,4 +14,9 @@ export class GetAssetCountByTimeBucketDto {
enumName: 'TimeGroupEnum',
})
timeGroup!: TimeGroupEnum;
@IsOptional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
}

View file

@ -12,9 +12,10 @@ import {
AuthController,
JobController,
OAuthController,
PartnerController,
SearchController,
ServerInfoController,
ShareController,
SharedLinkController,
SystemConfigController,
UserController,
} from './controllers';
@ -37,9 +38,10 @@ import { AppCronJobs } from './app.cron-jobs';
AuthController,
JobController,
OAuthController,
PartnerController,
SearchController,
ServerInfoController,
ShareController,
SharedLinkController,
SystemConfigController,
UserController,
],

View file

@ -3,8 +3,9 @@ export * from './api-key.controller';
export * from './auth.controller';
export * from './job.controller';
export * from './oauth.controller';
export * from './partner.controller';
export * from './search.controller';
export * from './server-info.controller';
export * from './share.controller';
export * from './shared-link.controller';
export * from './system-config.controller';
export * from './user.controller';

View file

@ -0,0 +1,36 @@
import { PartnerDirection, PartnerService, UserResponseDto } from '@app/domain';
import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Partner')
@Controller('partner')
@UseValidation()
export class PartnerController {
constructor(private service: PartnerService) {}
@Authenticated()
@Get()
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
getPartners(
@GetAuthUser() authUser: AuthUserDto,
@Query('direction') direction: PartnerDirection,
): Promise<UserResponseDto[]> {
return this.service.getAll(authUser, direction);
}
@Authenticated()
@Post(':id')
createPartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.create(authUser, id);
}
@Authenticated()
@Delete(':id')
removePartner(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
}

View file

@ -9,7 +9,7 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('share')
@Controller('share')
@UseValidation()
export class ShareController {
export class SharedLinkController {
constructor(private readonly service: ShareService) {}
@Authenticated()

View file

@ -792,6 +792,129 @@
]
}
},
"/partner": {
"get": {
"operationId": "getPartners",
"parameters": [
{
"name": "direction",
"required": true,
"in": "query",
"schema": {
"enum": [
"shared-by",
"shared-with"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
}
}
}
},
"tags": [
"Partner"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/partner/{id}": {
"post": {
"operationId": "createPartner",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
}
}
},
"tags": [
"Partner"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
},
"delete": {
"operationId": "removePartner",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Partner"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/search": {
"get": {
"operationId": "search",
@ -5419,6 +5542,10 @@
"properties": {
"timeGroup": {
"$ref": "#/components/schemas/TimeGroupEnum"
},
"userId": {
"type": "string",
"format": "uuid"
}
},
"required": [
@ -5504,6 +5631,10 @@
"items": {
"type": "string"
}
},
"userId": {
"type": "string",
"format": "uuid"
}
},
"required": [

View file

@ -6,31 +6,33 @@ import { AuthService } from './auth';
import { JobService } from './job';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { PartnerService } from './partner';
import { SearchService } from './search';
import { ServerInfoService } from './server-info';
import { ShareService } from './share';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
import { StorageTemplateService } from './storage-template';
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
import { UserService } from './user';
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
const providers: Provider[] = [
AlbumService,
AssetService,
APIKeyService,
AssetService,
AuthService,
JobService,
MediaService,
OAuthService,
PartnerService,
SearchService,
ServerInfoService,
ShareService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
ShareService,
SearchService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],

View file

@ -14,6 +14,7 @@ export * from './metadata';
export * from './oauth';
export * from './search';
export * from './server-info';
export * from './partner';
export * from './share';
export * from './smart-info';
export * from './storage';

View file

@ -0,0 +1,3 @@
export * from './partner.core';
export * from './partner.repository';
export * from './partner.service';

View file

@ -0,0 +1,33 @@
import { PartnerEntity } from '@app/infra/entities';
import { IPartnerRepository, PartnerIds } from './partner.repository';
export enum PartnerDirection {
SharedBy = 'shared-by',
SharedWith = 'shared-with',
}
export class PartnerCore {
constructor(private repository: IPartnerRepository) {}
async getAll(userId: string, direction: PartnerDirection): Promise<PartnerEntity[]> {
const partners = await this.repository.getAll(userId);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners.filter((partner) => partner[key] === userId);
}
get(ids: PartnerIds): Promise<PartnerEntity | null> {
return this.repository.get(ids);
}
async create(ids: PartnerIds): Promise<PartnerEntity> {
return this.repository.create(ids);
}
async remove(ids: PartnerIds): Promise<void> {
await this.repository.remove(ids as PartnerEntity);
}
hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
return this.repository.hasAssetAccess(assetId, userId);
}
}

View file

@ -0,0 +1,16 @@
import { PartnerEntity } from '@app/infra/entities';
export interface PartnerIds {
sharedById: string;
sharedWithId: string;
}
export const IPartnerRepository = 'IPartnerRepository';
export interface IPartnerRepository {
getAll(userId: string): Promise<PartnerEntity[]>;
get(partner: PartnerIds): Promise<PartnerEntity | null>;
create(partner: PartnerIds): Promise<PartnerEntity>;
remove(entity: PartnerEntity): Promise<void>;
hasAssetAccess(assetId: string, userId: string): Promise<boolean>;
}

View file

@ -0,0 +1,102 @@
import { BadRequestException } from '@nestjs/common';
import { authStub, newPartnerRepositoryMock, partnerStub } from '../../test';
import { PartnerDirection } from './partner.core';
import { IPartnerRepository } from './partner.repository';
import { PartnerService } from './partner.service';
const responseDto = {
admin: {
createdAt: '2021-01-01',
deletedAt: undefined,
email: 'admin@test.com',
firstName: 'admin_first_name',
id: 'admin_id',
isAdmin: true,
lastName: 'admin_last_name',
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
updatedAt: '2021-01-01',
},
user1: {
createdAt: '2021-01-01',
deletedAt: undefined,
email: 'immich@test.com',
firstName: 'immich_first_name',
id: 'immich_id',
isAdmin: false,
lastName: 'immich_last_name',
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
updatedAt: '2021-01-01',
},
};
describe(PartnerService.name, () => {
let sut: PartnerService;
let partnerMock: jest.Mocked<IPartnerRepository>;
beforeEach(async () => {
partnerMock = newPartnerRepositoryMock();
sut = new PartnerService(partnerMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it("should return a list of partners with whom I've shared my library", async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]);
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
it('should return a list of partners who have shared their libraries with me', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]);
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('create', () => {
it('should create a new partner', async () => {
partnerMock.get.mockResolvedValue(null);
partnerMock.create.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.id)).resolves.toEqual(responseDto.user1);
expect(partnerMock.create).toHaveBeenCalledWith({
sharedById: authStub.admin.id,
sharedWithId: authStub.user1.id,
});
});
it('should throw an error when the partner already exists', async () => {
partnerMock.get.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(partnerMock.create).not.toHaveBeenCalled();
});
});
describe('remove', () => {
it('should remove a partner', async () => {
partnerMock.get.mockResolvedValue(partnerStub.adminToUser1);
await sut.remove(authStub.admin, authStub.user1.id);
expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1);
});
it('should throw an error when the partner does not exist', async () => {
partnerMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.admin, authStub.user1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(partnerMock.remove).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,45 @@
import { PartnerEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IPartnerRepository, PartnerCore, PartnerDirection, PartnerIds } from '../partner';
import { mapUser, UserResponseDto } from '../user';
@Injectable()
export class PartnerService {
private partnerCore: PartnerCore;
constructor(@Inject(IPartnerRepository) partnerRepository: IPartnerRepository) {
this.partnerCore = new PartnerCore(partnerRepository);
}
async create(authUser: AuthUserDto, sharedWithId: string): Promise<UserResponseDto> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const exists = await this.partnerCore.get(partnerId);
if (exists) {
throw new BadRequestException(`Partner already exists`);
}
const partner = await this.partnerCore.create(partnerId);
return this.map(partner, PartnerDirection.SharedBy);
}
async remove(authUser: AuthUserDto, sharedWithId: string): Promise<void> {
const partnerId: PartnerIds = { sharedById: authUser.id, sharedWithId };
const partner = await this.partnerCore.get(partnerId);
if (!partner) {
throw new BadRequestException('Partner not found');
}
await this.partnerCore.remove(partner);
}
async getAll(authUser: AuthUserDto, direction: PartnerDirection): Promise<UserResponseDto[]> {
const partners = await this.partnerCore.getAll(authUser.id, direction);
return partners.map((partner) => this.map(partner, direction));
}
private map(partner: PartnerEntity, direction: PartnerDirection): UserResponseDto {
// this is opposite to return the non-me user of the "partner"
return mapUser(direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy);
}
}

View file

@ -1,11 +1,5 @@
import { AssetEntity, SharedLinkEntity } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { BadRequestException, ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { CreateSharedLinkDto } from './dto';
@ -25,24 +19,19 @@ export class ShareCore {
}
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
try {
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
description: dto.description,
userId,
createdAt: new Date().toISOString(),
expiresAt: dto.expiresAt ?? null,
type: dto.type,
assets: dto.assets,
album: dto.album,
allowUpload: dto.allowUpload ?? false,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
} catch (error: any) {
this.logger.error(error, error.stack);
throw new InternalServerErrorException('failed to create shared link');
}
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
description: dto.description,
userId,
createdAt: new Date().toISOString(),
expiresAt: dto.expiresAt ?? null,
type: dto.type,
assets: dto.assets,
album: dto.album,
allowUpload: dto.allowUpload ?? false,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
}
async save(userId: string, id: string, entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
@ -54,13 +43,13 @@ export class ShareCore {
return this.repository.save({ ...entity, userId, id });
}
async remove(userId: string, id: string): Promise<SharedLinkEntity> {
async remove(userId: string, id: string): Promise<void> {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.remove(link);
await this.repository.remove(link);
}
async addAssets(userId: string, id: string, assets: AssetEntity[]) {

View file

@ -7,7 +7,7 @@ export interface ISharedLinkRepository {
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
getByKey(key: string): Promise<SharedLinkEntity | null>;
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<void>;
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}

View file

@ -3,6 +3,7 @@ import {
APIKeyEntity,
AssetEntity,
AssetType,
PartnerEntity,
SharedLinkEntity,
SharedLinkType,
SystemConfig,
@ -824,3 +825,22 @@ export const probeStub = {
},
}),
};
export const partnerStub = {
adminToUser1: Object.freeze<PartnerEntity>({
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
sharedById: userEntityStub.admin.id,
sharedBy: userEntityStub.admin,
sharedWith: userEntityStub.user1,
sharedWithId: userEntityStub.user1.id,
}),
user1ToAdmin1: Object.freeze<PartnerEntity>({
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
sharedBy: userEntityStub.user1,
sharedById: userEntityStub.user1.id,
sharedWithId: userEntityStub.admin.id,
sharedWith: userEntityStub.admin,
}),
};

View file

@ -7,6 +7,7 @@ export * from './fixtures';
export * from './job.repository.mock';
export * from './machine-learning.repository.mock';
export * from './media.repository.mock';
export * from './partner.repository.mock';
export * from './search.repository.mock';
export * from './shared-link.repository.mock';
export * from './smart-info.repository.mock';

View file

@ -0,0 +1,11 @@
import { IPartnerRepository } from '../src';
export const newPartnerRepositoryMock = (): jest.Mocked<IPartnerRepository> => {
return {
create: jest.fn(),
remove: jest.fn(),
getAll: jest.fn(),
get: jest.fn(),
hasAssetAccess: jest.fn(),
};
};

View file

@ -1,16 +1,18 @@
import { AlbumEntity } from './album.entity';
import { APIKeyEntity } from './api-key.entity';
import { AssetEntity } from './asset.entity';
import { PartnerEntity } from './partner.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { SmartInfoEntity } from './smart-info.entity';
import { SystemConfigEntity } from './system-config.entity';
import { UserTokenEntity } from './user-token.entity';
import { UserEntity } from './user.entity';
import { UserTokenEntity } from './user-token.entity';
export * from './album.entity';
export * from './api-key.entity';
export * from './asset.entity';
export * from './exif.entity';
export * from './partner.entity';
export * from './shared-link.entity';
export * from './smart-info.entity';
export * from './system-config.entity';
@ -19,12 +21,13 @@ export * from './user-token.entity';
export * from './user.entity';
export const databaseEntities = [
AssetEntity,
AlbumEntity,
APIKeyEntity,
UserEntity,
AssetEntity,
PartnerEntity,
SharedLinkEntity,
SmartInfoEntity,
SystemConfigEntity,
UserEntity,
UserTokenEntity,
];

View file

@ -0,0 +1,26 @@
import { CreateDateColumn, Entity, ManyToOne, PrimaryColumn, JoinColumn, UpdateDateColumn } from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('partners')
export class PartnerEntity {
@PrimaryColumn('uuid')
sharedById!: string;
@PrimaryColumn('uuid')
sharedWithId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'sharedById' })
sharedBy!: UserEntity;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'sharedWithId' })
sharedWith!: UserEntity;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View file

@ -9,6 +9,7 @@ import {
IMachineLearningRepository,
IMediaRepository,
immichAppConfig,
IPartnerRepository,
ISearchRepository,
ISharedLinkRepository,
ISmartInfoRepository,
@ -36,6 +37,7 @@ import {
JobRepository,
MachineLearningRepository,
MediaRepository,
PartnerRepository,
SharedLinkRepository,
SmartInfoRepository,
SystemConfigRepository,
@ -54,6 +56,7 @@ const providers: Provider[] = [
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: ISearchRepository, useClass: TypesenseRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddPartnersTable1683808254676 implements MigrationInterface {
name = 'AddPartnersTable1683808254676'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "partners" ("sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_f1cc8f73d16b367f426261a8736" PRIMARY KEY ("sharedById", "sharedWithId"))`);
await queryRunner.query(`ALTER TABLE "partners" ADD CONSTRAINT "FK_7e077a8b70b3530138610ff5e04" FOREIGN KEY ("sharedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "partners" ADD CONSTRAINT "FK_d7e875c6c60e661723dbf372fd3" FOREIGN KEY ("sharedWithId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "partners" DROP CONSTRAINT "FK_d7e875c6c60e661723dbf372fd3"`);
await queryRunner.query(`ALTER TABLE "partners" DROP CONSTRAINT "FK_7e077a8b70b3530138610ff5e04"`);
await queryRunner.query(`DROP TABLE "partners"`);
}
}

View file

@ -8,6 +8,7 @@ export * from './geocoding.repository';
export * from './job.repository';
export * from './machine-learning.repository';
export * from './media.repository';
export * from './partner.repository';
export * from './shared-link.repository';
export * from './smart-info.repository';
export * from './system-config.repository';

View file

@ -0,0 +1,50 @@
import { IPartnerRepository, PartnerIds } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PartnerEntity } from '../entities';
@Injectable()
export class PartnerRepository implements IPartnerRepository {
constructor(@InjectRepository(PartnerEntity) private readonly repository: Repository<PartnerEntity>) {}
getAll(userId: string): Promise<PartnerEntity[]> {
return this.repository.find({ where: [{ sharedWithId: userId }, { sharedById: userId }] });
}
get({ sharedWithId, sharedById }: PartnerIds): Promise<PartnerEntity | null> {
return this.repository.findOne({ where: { sharedById, sharedWithId } });
}
async create({ sharedById, sharedWithId }: PartnerIds): Promise<PartnerEntity> {
await this.repository.save({ sharedBy: { id: sharedById }, sharedWith: { id: sharedWithId } });
return this.repository.findOneOrFail({ where: { sharedById, sharedWithId } });
}
async remove(entity: PartnerEntity): Promise<void> {
await this.repository.remove(entity);
}
async hasAssetAccess(assetId: string, userId: string): Promise<boolean> {
const count = await this.repository.count({
where: {
sharedWith: {
id: userId,
},
sharedBy: {
assets: {
id: assetId,
},
},
},
relations: {
sharedWith: true,
sharedBy: {
assets: true,
},
},
});
return count == 1;
}
}

View file

@ -82,8 +82,8 @@ export class SharedLinkRepository implements ISharedLinkRepository {
return this.repository.save(entity);
}
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return this.repository.remove(entity);
async remove(entity: SharedLinkEntity): Promise<void> {
await this.repository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {

View file

@ -8,6 +8,7 @@ import {
ConfigurationParameters,
JobApi,
OAuthApi,
PartnerApi,
SearchApi,
ServerInfoApi,
ShareApi,
@ -20,34 +21,36 @@ import { DUMMY_BASE_URL, toPathString } from './open-api/common';
import type { ApiParams } from './types';
export class ImmichApi {
public userApi: UserApi;
public albumApi: AlbumApi;
public assetApi: AssetApi;
public authenticationApi: AuthenticationApi;
public oauthApi: OAuthApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
public systemConfigApi: SystemConfigApi;
public oauthApi: OAuthApi;
public partnerApi: PartnerApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public shareApi: ShareApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
private config: Configuration;
constructor(params: ConfigurationParameters) {
this.config = new Configuration(params);
this.userApi = new UserApi(this.config);
this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.partnerApi = new PartnerApi(this.config);
this.searchApi = new SearchApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.shareApi = new ShareApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);
}
private createUrl(path: string, params?: Record<string, unknown>) {

View file

@ -1210,6 +1210,12 @@ export interface GetAssetByTimeBucketDto {
* @memberof GetAssetByTimeBucketDto
*/
'timeBucket': Array<string>;
/**
*
* @type {string}
* @memberof GetAssetByTimeBucketDto
*/
'userId'?: string;
}
/**
*
@ -1223,6 +1229,12 @@ export interface GetAssetCountByTimeBucketDto {
* @memberof GetAssetCountByTimeBucketDto
*/
'timeGroup': TimeGroupEnum;
/**
*
* @type {string}
* @memberof GetAssetCountByTimeBucketDto
*/
'userId'?: string;
}
@ -7191,6 +7203,263 @@ export class OAuthApi extends BaseAPI {
}
/**
* PartnerApi - axios parameter creator
* @export
*/
export const PartnerApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPartner: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('createPartner', 'id', id)
const localVarPath = `/partner/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPartners: async (direction: 'shared-by' | 'shared-with', options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'direction' is not null or undefined
assertParamExists('getPartners', 'direction', direction)
const localVarPath = `/partner`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (direction !== undefined) {
localVarQueryParameter['direction'] = direction;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removePartner: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('removePartner', 'id', id)
const localVarPath = `/partner/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* PartnerApi - functional programming interface
* @export
*/
export const PartnerApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = PartnerApiAxiosParamCreator(configuration)
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createPartner(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createPartner(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPartners(direction: 'shared-by' | 'shared-with', options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPartners(direction, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async removePartner(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removePartner(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* PartnerApi - factory interface
* @export
*/
export const PartnerApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = PartnerApiFp(configuration)
return {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createPartner(id: string, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.createPartner(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPartners(direction: 'shared-by' | 'shared-with', options?: any): AxiosPromise<Array<UserResponseDto>> {
return localVarFp.getPartners(direction, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removePartner(id: string, options?: any): AxiosPromise<void> {
return localVarFp.removePartner(id, options).then((request) => request(axios, basePath));
},
};
};
/**
* PartnerApi - object-oriented interface
* @export
* @class PartnerApi
* @extends {BaseAPI}
*/
export class PartnerApi extends BaseAPI {
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PartnerApi
*/
public createPartner(id: string, options?: AxiosRequestConfig) {
return PartnerApiFp(this.configuration).createPartner(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {'shared-by' | 'shared-with'} direction
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PartnerApi
*/
public getPartners(direction: 'shared-by' | 'shared-with', options?: AxiosRequestConfig) {
return PartnerApiFp(this.configuration).getPartners(direction, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PartnerApi
*/
public removePartner(id: string, options?: AxiosRequestConfig) {
return PartnerApiFp(this.configuration).removePartner(id, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* SearchApi - axios parameter creator
* @export

View file

@ -8,6 +8,7 @@
import { goto } from '$app/navigation';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import Button from '../elements/buttons/button.svelte';
import { AppRoute } from '$lib/constants';
export let album: AlbumResponseDto;
export let sharedUsersInAlbum: Set<UserResponseDto>;
@ -138,7 +139,7 @@
{#if sharedLinks.length}
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={() => goto('/sharing/sharedlinks')}
on:click={() => goto(AppRoute.SHARED_LINKS)}
>
<ShareCircle size={24} />
<p class="text-sm">View links</p>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { UserResponseDto } from '@api';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum } from '@api';
@ -17,6 +18,7 @@
OnScrollbarDragDetail
} from '../shared-components/scrollbar/scrollbar.svelte';
export let user: UserResponseDto | undefined = undefined;
export let isAlbumSelectionMode = false;
let viewportHeight = 0;
@ -26,11 +28,12 @@
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
timeGroup: TimeGroupEnum.Month
timeGroup: TimeGroupEnum.Month,
userId: user?.id
});
bucketInfo = assetCountByTimebucket;
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
// Get asset bucket if bucket height is smaller than viewport height
let bucketsToFetchInitially: string[] = [];
@ -50,6 +53,10 @@
});
});
onDestroy(() => {
assetStore.setInitialState(0, 0, { totalCount: 0, buckets: [] }, undefined);
});
function intersectedHandler(event: CustomEvent) {
const el = event.detail as HTMLElement;
const target = el.firstChild as HTMLElement;

View file

@ -31,7 +31,7 @@
id="immich-modal"
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed top-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
>
<div
use:clickOutside

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import Button from '../elements/buttons/button.svelte';
import { createEventDispatcher, onMount } from 'svelte';
let availableUsers: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher<{ close: void; 'add-users': UserResponseDto[] }>();
onMount(async () => {
// TODO: update endpoint to have a query param for deleted users
let { data: users } = await api.userApi.getAllUsers(false);
// remove soft deleted users
users = users.filter((user) => !user.deletedAt);
// exclude partners from the list of users available for selection
const { data: partners } = await api.partnerApi.getPartners('shared-by');
const partnerIds = partners.map((partner) => partner.id);
availableUsers = users.filter((user) => !partnerIds.includes(user.id));
});
const selectUser = (user: UserResponseDto) => {
if (selectedUsers.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
} else {
selectedUsers = [...selectedUsers, user];
}
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<ImmichLogo width={24} />
<p class="font-medium">Add partner</p>
</span>
</svelte:fragment>
<div class="max-h-[300px] overflow-y-auto immich-scrollbar">
{#if availableUsers.length > 0}
{#each availableUsers as user}
<button
on:click={() => selectUser(user)}
class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
>
{#if selectedUsers.includes(user)}
<span
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-bg rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl dark:border-immich-dark-gray"
>✓</span
>
{:else}
<CircleAvatar {user} />
{/if}
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.firstName}
{user.lastName}
</p>
<p class="text-xs ">
{user.email}
</p>
</div>
</button>
{/each}
{:else}
<p class="text-sm p-5">
Looks like you shared your photos with all users or you don't have any user to share with.
</p>
{/if}
{#if selectedUsers.length > 0}
<div class="flex place-content-end p-5 ">
<Button size="sm" rounded="lg" on:click={() => dispatch('add-users', selectedUsers)}>
Add
</Button>
</div>
{/if}
</div>
</BaseModal>

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { UserResponseDto, api } from '@api';
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import Button from '../elements/buttons/button.svelte';
import PartnerSelectionModal from './partner-selection-modal.svelte';
import { handleError } from '../../utils/handle-error';
import { onMount } from 'svelte';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
let partners: UserResponseDto[] = [];
let createPartner = false;
let removePartner: UserResponseDto | null = null;
const refreshPartners = async () => {
const { data } = await api.partnerApi.getPartners('shared-by');
partners = data;
};
const handleRemovePartner = async () => {
if (!removePartner) {
return;
}
try {
await api.partnerApi.removePartner(removePartner.id);
removePartner = null;
await refreshPartners();
} catch (error) {
handleError(error, 'Unable to remove partner');
}
};
const handleCreatePartners = async (users: UserResponseDto[]) => {
try {
for (const user of users) {
await api.partnerApi.createPartner(user.id);
}
await refreshPartners();
createPartner = false;
} catch (error) {
handleError(error, 'Unable to add partners');
}
};
onMount(async () => {
await refreshPartners();
});
</script>
<section class="my-4">
{#if partners.length > 0}
<div class="flex flex-row gap-4">
{#each partners as partner}
<div class="flex rounded-lg gap-4 py-4 px-5 transition-all">
<CircleAvatar user={partner} />
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.firstName}
{partner.lastName}
</p>
<p class="text-xs text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.email}
</p>
</div>
<CircleIconButton
on:click={() => (removePartner = partner)}
logo={Close}
size={'16'}
title="Remove partner"
/>
</div>
{/each}
</div>
{/if}
<div class="flex justify-end">
<Button size="sm" on:click={() => (createPartner = true)}>Add partner</Button>
</div>
</section>
{#if createPartner}
<PartnerSelectionModal
on:close={() => (createPartner = false)}
on:add-users={(event) => handleCreatePartners(event.detail)}
/>
{/if}
{#if removePartner}
<ConfirmDialogue
title="Stop sharing your photos?"
prompt="{removePartner.firstName} will no longer be able to access your photos."
on:cancel={() => (removePartner = null)}
on:confirm={() => handleRemovePartner()}
/>
{/if}

View file

@ -7,6 +7,7 @@
import OAuthSettings from './oauth-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import DeviceList from './device-list.svelte';
import PartnerSettings from './partner-settings.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
export let user: UserResponseDto;
@ -51,3 +52,7 @@
<SettingAccordion title="Password" subtitle="Change your password">
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion title="Sharing" subtitle="Manage sharing with partners">
<PartnerSettings />
</SettingAccordion>

View file

@ -13,6 +13,7 @@ export enum AppRoute {
PHOTOS = '/photos',
EXPLORE = '/explore',
SHARING = '/sharing',
SHARED_LINKS = '/sharing/sharedlinks',
SEARCH = '/search',
MAP = '/map',

View file

@ -37,4 +37,9 @@ export class AssetGridState {
* Total assets that have been loaded
*/
assets: AssetResponseDto[] = [];
/**
* User that owns assets
*/
userId: string | undefined;
}

View file

@ -29,7 +29,8 @@ function createAssetStore() {
const setInitialState = (
viewportHeight: number,
viewportWidth: number,
data: AssetCountByTimeBucketResponseDto
data: AssetCountByTimeBucketResponseDto,
userId: string | undefined
) => {
assetGridState.set({
viewportHeight,
@ -41,7 +42,8 @@ function createAssetStore() {
assets: [],
cancelToken: new AbortController()
})),
assets: []
assets: [],
userId
});
// Update timeline height based on calculated bucket height
@ -64,7 +66,8 @@ function createAssetStore() {
});
const { data: assets } = await api.assetApi.getAssetByTimeBucket(
{
timeBucket: [bucket]
timeBucket: [bucket],
userId: _assetGridState.userId
},
{ signal: currentBucketData?.cancelToken.signal }
);

View file

@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { AppRoute } from '$lib/constants';
export const load: PageServerLoad = async ({ params, parent, locals: { api } }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
const { data: partner } = await api.userApi.getUserById(params['userId']);
return {
user,
partner,
meta: {
title: 'Partner'
}
};
};

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { PageData } from './$types';
import { AppRoute } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { goto } from '$app/navigation';
import { bulkDownload } from '$lib/utils/asset-utils';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import {
assetInteractionStore,
isMultiSelectStoreState,
selectedAssets
} from '$lib/stores/asset-interaction.store';
export let data: PageData;
const handleDownloadFiles = async () => {
await bulkDownload('immich', Array.from($selectedAssets), () => {
assetInteractionStore.clearMultiselect();
});
};
</script>
<main class="grid h-screen pt-[4.25rem] bg-immich-bg dark:bg-immich-dark-bg">
{#if $isMultiSelectStoreState}
<ControlAppBar
showBackButton
backIcon={Close}
on:close-button-click={() => assetInteractionStore.clearMultiselect()}
tailwindClasses={'bg-white shadow-md'}
>
<svelte:fragment slot="leading">
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
Selected {$selectedAssets.size.toLocaleString($locale)}
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Download"
logo={CloudDownloadOutline}
on:click={handleDownloadFiles}
/>
</svelte:fragment>
</ControlAppBar>
{:else}
<ControlAppBar
showBackButton
backIcon={ArrowLeft}
on:close-button-click={() => goto(AppRoute.SHARING)}
>
<svelte:fragment slot="leading">
<p class="text-immich-fg dark:text-immich-dark-fg">
{data.partner.firstName}
{data.partner.lastName}'s photos
</p>
</svelte:fragment>
</ControlAppBar>
{/if}
<AssetGrid user={data.partner} />
</main>

View file

@ -9,15 +9,18 @@ export const load = (async ({ locals: { api, user } }) => {
try {
const { data: sharedAlbums } = await api.albumApi.getAllAlbums(true);
const { data: partners } = await api.partnerApi.getPartners('shared-with');
return {
user,
sharedAlbums,
partners,
meta: {
title: 'Sharing'
}
};
} catch (e) {
console.log(e);
throw redirect(302, AppRoute.AUTH_LOGIN);
}
}) satisfies PageServerLoad;

View file

@ -13,6 +13,8 @@
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import { flip } from 'svelte/animate';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import CircleAvatar from '$lib/components/shared-components/circle-avatar.svelte';
import { AppRoute } from '$lib/constants';
export let data: PageData;
@ -43,7 +45,7 @@
</div>
</LinkButton>
<LinkButton on:click={() => goto('/sharing/sharedlinks')}>
<LinkButton on:click={() => goto(AppRoute.SHARED_LINKS)}>
<div class="flex place-items-center gap-x-1 text-sm flex-wrap justify-center">
<Link size="18" class="shrink-0" />
<span class="max-sm:text-xs leading-none">Shared links</span>
@ -51,29 +53,69 @@
</LinkButton>
</div>
<section>
<div class="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]">
{#each data.sharedAlbums as album (album.id)}
<a
data-sveltekit-preload-data="hover"
href={`albums/${album.id}`}
animate:flip={{ duration: 200 }}
>
<AlbumCard {album} user={data.user} isSharingView />
</a>
{/each}
</div>
<div class="mx-4 flex flex-col">
{#if data.partners.length > 0}
<div class="mb-6 mt-2">
<div>
<p class="mb-4 dark:text-immich-dark-fg font-medium">Partners</p>
</div>
<!-- Empty List -->
{#if data.sharedAlbums.length === 0}
<div
class="border dark:border-immich-dark-gray p-5 md:w-[500px] w-2/3 m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center dark:text-immich-dark-fg"
>
<img src={empty2Url} alt="Empty shared album" width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network
</p>
<div class="flex flex-row flex-wrap gap-4">
{#each data.partners as partner}
<button
on:click={() => goto(`/partners/${partner.id}`)}
class="flex rounded-lg gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
>
<CircleAvatar user={partner} />
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{partner.firstName}
{partner.lastName}
</p>
<p class="text-xs text-immich-fg/75 dark:text-immich-dark-fg/75">
{partner.email}
</p>
</div>
</button>
{/each}
</div>
</div>
<hr class="dark:border-immich-dark-gray mb-4" />
{/if}
</section>
<div class="mb-6 mt-2">
<div>
<p class="mb-4 dark:text-immich-dark-fg font-medium">Albums</p>
</div>
<div>
<!-- Share Album List -->
<div class="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]">
{#each data.sharedAlbums as album (album.id)}
<a
data-sveltekit-preload-data="hover"
href={`albums/${album.id}`}
animate:flip={{ duration: 200 }}
>
<AlbumCard {album} user={data.user} isSharingView />
</a>
{/each}
</div>
<!-- Empty List -->
{#if data.sharedAlbums.length === 0}
<div
class="border dark:border-immich-dark-gray p-5 md:w-[500px] w-2/3 m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center dark:text-immich-dark-fg"
>
<img src={empty2Url} alt="Empty shared album" width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network
</p>
</div>
{/if}
</div>
</div>
</div>
</UserPageLayout>