mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
Merge 818dba507c into 9d639607c7
This commit is contained in:
commit
c2e203e2bf
19 changed files with 516 additions and 15 deletions
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
|
|
@ -407,6 +407,7 @@ Class | Method | HTTP request | Description
|
||||||
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
||||||
- [MergePersonDto](doc//MergePersonDto.md)
|
- [MergePersonDto](doc//MergePersonDto.md)
|
||||||
- [MetadataSearchDto](doc//MetadataSearchDto.md)
|
- [MetadataSearchDto](doc//MetadataSearchDto.md)
|
||||||
|
- [MobileDeviceDto](doc//MobileDeviceDto.md)
|
||||||
- [NotificationCreateDto](doc//NotificationCreateDto.md)
|
- [NotificationCreateDto](doc//NotificationCreateDto.md)
|
||||||
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
|
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
|
||||||
- [NotificationDto](doc//NotificationDto.md)
|
- [NotificationDto](doc//NotificationDto.md)
|
||||||
|
|
|
||||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
|
@ -178,6 +178,7 @@ part 'model/memory_type.dart';
|
||||||
part 'model/memory_update_dto.dart';
|
part 'model/memory_update_dto.dart';
|
||||||
part 'model/merge_person_dto.dart';
|
part 'model/merge_person_dto.dart';
|
||||||
part 'model/metadata_search_dto.dart';
|
part 'model/metadata_search_dto.dart';
|
||||||
|
part 'model/mobile_device_dto.dart';
|
||||||
part 'model/notification_create_dto.dart';
|
part 'model/notification_create_dto.dart';
|
||||||
part 'model/notification_delete_all_dto.dart';
|
part 'model/notification_delete_all_dto.dart';
|
||||||
part 'model/notification_dto.dart';
|
part 'model/notification_dto.dart';
|
||||||
|
|
|
||||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
|
@ -410,6 +410,8 @@ class ApiClient {
|
||||||
return MergePersonDto.fromJson(value);
|
return MergePersonDto.fromJson(value);
|
||||||
case 'MetadataSearchDto':
|
case 'MetadataSearchDto':
|
||||||
return MetadataSearchDto.fromJson(value);
|
return MetadataSearchDto.fromJson(value);
|
||||||
|
case 'MobileDeviceDto':
|
||||||
|
return MobileDeviceDto.fromJson(value);
|
||||||
case 'NotificationCreateDto':
|
case 'NotificationCreateDto':
|
||||||
return NotificationCreateDto.fromJson(value);
|
return NotificationCreateDto.fromJson(value);
|
||||||
case 'NotificationDeleteAllDto':
|
case 'NotificationDeleteAllDto':
|
||||||
|
|
|
||||||
123
mobile/openapi/lib/model/mobile_device_dto.dart
generated
Normal file
123
mobile/openapi/lib/model/mobile_device_dto.dart
generated
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// 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 MobileDeviceDto {
|
||||||
|
/// Returns a new [MobileDeviceDto] instance.
|
||||||
|
MobileDeviceDto({
|
||||||
|
required this.appVersion,
|
||||||
|
required this.deviceOS,
|
||||||
|
required this.deviceType,
|
||||||
|
required this.lastSeen,
|
||||||
|
});
|
||||||
|
|
||||||
|
String appVersion;
|
||||||
|
|
||||||
|
String deviceOS;
|
||||||
|
|
||||||
|
String deviceType;
|
||||||
|
|
||||||
|
DateTime lastSeen;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is MobileDeviceDto &&
|
||||||
|
other.appVersion == appVersion &&
|
||||||
|
other.deviceOS == deviceOS &&
|
||||||
|
other.deviceType == deviceType &&
|
||||||
|
other.lastSeen == lastSeen;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(appVersion.hashCode) +
|
||||||
|
(deviceOS.hashCode) +
|
||||||
|
(deviceType.hashCode) +
|
||||||
|
(lastSeen.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MobileDeviceDto[appVersion=$appVersion, deviceOS=$deviceOS, deviceType=$deviceType, lastSeen=$lastSeen]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'appVersion'] = this.appVersion;
|
||||||
|
json[r'deviceOS'] = this.deviceOS;
|
||||||
|
json[r'deviceType'] = this.deviceType;
|
||||||
|
json[r'lastSeen'] = this.lastSeen.toUtc().toIso8601String();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [MobileDeviceDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static MobileDeviceDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "MobileDeviceDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return MobileDeviceDto(
|
||||||
|
appVersion: mapValueOfType<String>(json, r'appVersion')!,
|
||||||
|
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||||
|
deviceType: mapValueOfType<String>(json, r'deviceType')!,
|
||||||
|
lastSeen: mapDateTime(json, r'lastSeen', r'')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MobileDeviceDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <MobileDeviceDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = MobileDeviceDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, MobileDeviceDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, MobileDeviceDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = MobileDeviceDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of MobileDeviceDto-objects as value to a dart map
|
||||||
|
static Map<String, List<MobileDeviceDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<MobileDeviceDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = MobileDeviceDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'appVersion',
|
||||||
|
'deviceOS',
|
||||||
|
'deviceType',
|
||||||
|
'lastSeen',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ class UserAdminResponseDto {
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.isAdmin,
|
required this.isAdmin,
|
||||||
required this.license,
|
required this.license,
|
||||||
|
this.mobileDevices = const [],
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.oauthId,
|
required this.oauthId,
|
||||||
required this.profileChangedAt,
|
required this.profileChangedAt,
|
||||||
|
|
@ -46,6 +47,8 @@ class UserAdminResponseDto {
|
||||||
|
|
||||||
UserLicense? license;
|
UserLicense? license;
|
||||||
|
|
||||||
|
List<MobileDeviceDto> mobileDevices;
|
||||||
|
|
||||||
String name;
|
String name;
|
||||||
|
|
||||||
String oauthId;
|
String oauthId;
|
||||||
|
|
@ -75,6 +78,7 @@ class UserAdminResponseDto {
|
||||||
other.id == id &&
|
other.id == id &&
|
||||||
other.isAdmin == isAdmin &&
|
other.isAdmin == isAdmin &&
|
||||||
other.license == license &&
|
other.license == license &&
|
||||||
|
_deepEquality.equals(other.mobileDevices, mobileDevices) &&
|
||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.oauthId == oauthId &&
|
other.oauthId == oauthId &&
|
||||||
other.profileChangedAt == profileChangedAt &&
|
other.profileChangedAt == profileChangedAt &&
|
||||||
|
|
@ -96,6 +100,7 @@ class UserAdminResponseDto {
|
||||||
(id.hashCode) +
|
(id.hashCode) +
|
||||||
(isAdmin.hashCode) +
|
(isAdmin.hashCode) +
|
||||||
(license == null ? 0 : license!.hashCode) +
|
(license == null ? 0 : license!.hashCode) +
|
||||||
|
(mobileDevices.hashCode) +
|
||||||
(name.hashCode) +
|
(name.hashCode) +
|
||||||
(oauthId.hashCode) +
|
(oauthId.hashCode) +
|
||||||
(profileChangedAt.hashCode) +
|
(profileChangedAt.hashCode) +
|
||||||
|
|
@ -108,7 +113,7 @@ class UserAdminResponseDto {
|
||||||
(updatedAt.hashCode);
|
(updatedAt.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
|
String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, mobileDevices=$mobileDevices, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
|
@ -127,6 +132,7 @@ class UserAdminResponseDto {
|
||||||
} else {
|
} else {
|
||||||
// json[r'license'] = null;
|
// json[r'license'] = null;
|
||||||
}
|
}
|
||||||
|
json[r'mobileDevices'] = this.mobileDevices;
|
||||||
json[r'name'] = this.name;
|
json[r'name'] = this.name;
|
||||||
json[r'oauthId'] = this.oauthId;
|
json[r'oauthId'] = this.oauthId;
|
||||||
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
|
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
|
||||||
|
|
@ -168,6 +174,7 @@ class UserAdminResponseDto {
|
||||||
id: mapValueOfType<String>(json, r'id')!,
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
|
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
|
||||||
license: UserLicense.fromJson(json[r'license']),
|
license: UserLicense.fromJson(json[r'license']),
|
||||||
|
mobileDevices: MobileDeviceDto.listFromJson(json[r'mobileDevices']),
|
||||||
name: mapValueOfType<String>(json, r'name')!,
|
name: mapValueOfType<String>(json, r'name')!,
|
||||||
oauthId: mapValueOfType<String>(json, r'oauthId')!,
|
oauthId: mapValueOfType<String>(json, r'oauthId')!,
|
||||||
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
|
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
|
||||||
|
|
@ -232,6 +239,7 @@ class UserAdminResponseDto {
|
||||||
'id',
|
'id',
|
||||||
'isAdmin',
|
'isAdmin',
|
||||||
'license',
|
'license',
|
||||||
|
'mobileDevices',
|
||||||
'name',
|
'name',
|
||||||
'oauthId',
|
'oauthId',
|
||||||
'profileChangedAt',
|
'profileChangedAt',
|
||||||
|
|
|
||||||
|
|
@ -12724,6 +12724,30 @@
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MobileDeviceDto": {
|
||||||
|
"properties": {
|
||||||
|
"appVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"deviceOS": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"deviceType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastSeen": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"appVersion",
|
||||||
|
"deviceOS",
|
||||||
|
"deviceType",
|
||||||
|
"lastSeen"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"NotificationCreateDto": {
|
"NotificationCreateDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -17523,6 +17547,12 @@
|
||||||
],
|
],
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"mobileDevices": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MobileDeviceDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -17573,6 +17603,7 @@
|
||||||
"id",
|
"id",
|
||||||
"isAdmin",
|
"isAdmin",
|
||||||
"license",
|
"license",
|
||||||
|
"mobileDevices",
|
||||||
"name",
|
"name",
|
||||||
"oauthId",
|
"oauthId",
|
||||||
"profileChangedAt",
|
"profileChangedAt",
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,12 @@ export type UserLicense = {
|
||||||
activationKey: string;
|
activationKey: string;
|
||||||
licenseKey: string;
|
licenseKey: string;
|
||||||
};
|
};
|
||||||
|
export type MobileDeviceDto = {
|
||||||
|
appVersion: string;
|
||||||
|
deviceOS: string;
|
||||||
|
deviceType: string;
|
||||||
|
lastSeen: string;
|
||||||
|
};
|
||||||
export type UserAdminResponseDto = {
|
export type UserAdminResponseDto = {
|
||||||
avatarColor: UserAvatarColor;
|
avatarColor: UserAvatarColor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -95,6 +101,7 @@ export type UserAdminResponseDto = {
|
||||||
id: string;
|
id: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
license: (UserLicense) | null;
|
license: (UserLicense) | null;
|
||||||
|
mobileDevices: MobileDeviceDto[];
|
||||||
name: string;
|
name: string;
|
||||||
oauthId: string;
|
oauthId: string;
|
||||||
profileChangedAt: string;
|
profileChangedAt: string;
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ export type UserAdmin = User & {
|
||||||
quotaUsageInBytes: number;
|
quotaUsageInBytes: number;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
metadata: UserMetadataItem[];
|
metadata: UserMetadataItem[];
|
||||||
|
sessions: Session[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StorageAsset = {
|
export type StorageAsset = {
|
||||||
|
|
@ -238,6 +239,7 @@ export type Session = {
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
deviceOS: string;
|
deviceOS: string;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
|
appVersion: string;
|
||||||
pinExpiresAt: Date | null;
|
pinExpiresAt: Date | null;
|
||||||
isPendingSyncReset: boolean;
|
isPendingSyncReset: boolean;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,20 @@ import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
||||||
|
|
||||||
|
export class MobileDeviceDto {
|
||||||
|
@ApiProperty()
|
||||||
|
deviceType!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
deviceOS!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
appVersion!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
lastSeen!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export class UserUpdateMeDto {
|
export class UserUpdateMeDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
@IsEmail({ require_tld: false })
|
@IsEmail({ require_tld: false })
|
||||||
|
|
@ -152,6 +166,8 @@ export class UserAdminDeleteDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserAdminResponseDto extends UserResponseDto {
|
export class UserAdminResponseDto extends UserResponseDto {
|
||||||
|
@ApiProperty({ type: () => [MobileDeviceDto] })
|
||||||
|
mobileDevices!: MobileDeviceDto[];
|
||||||
storageLabel!: string | null;
|
storageLabel!: string | null;
|
||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
isAdmin!: boolean;
|
isAdmin!: boolean;
|
||||||
|
|
@ -173,6 +189,15 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
||||||
const license = metadata.find(
|
const license = metadata.find(
|
||||||
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
||||||
)?.value;
|
)?.value;
|
||||||
|
const mobileDevices = (entity.sessions || [])
|
||||||
|
.filter(({ appVersion }) => appVersion)
|
||||||
|
.map(({ deviceType, deviceOS, appVersion, updatedAt }) => ({
|
||||||
|
deviceType,
|
||||||
|
deviceOS,
|
||||||
|
appVersion,
|
||||||
|
lastSeen: updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mapUser(entity),
|
...mapUser(entity),
|
||||||
storageLabel: entity.storageLabel,
|
storageLabel: entity.storageLabel,
|
||||||
|
|
@ -186,5 +211,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
||||||
quotaUsageInBytes: entity.quotaUsageInBytes,
|
quotaUsageInBytes: entity.quotaUsageInBytes,
|
||||||
status: entity.status,
|
status: entity.status,
|
||||||
license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null,
|
license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null,
|
||||||
|
mobileDevices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,22 @@ export const FileResponse = () =>
|
||||||
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
|
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getAppVersionFromUA = (ua: string) =>
|
||||||
|
ua.match(/^Immich_(?:Android|iOS)_(?<appVersion>.+)$/)?.groups?.appVersion ?? '';
|
||||||
|
|
||||||
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
|
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const userAgent = UAParser(request.headers['user-agent']);
|
const userAgentString = request.get('user-agent') || '';
|
||||||
|
const userAgent = UAParser(userAgentString);
|
||||||
|
|
||||||
|
const appVersion = getAppVersionFromUA(userAgentString);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientIp: request.ip ?? '',
|
clientIp: request.ip ?? '',
|
||||||
isSecure: request.secure,
|
isSecure: request.secure,
|
||||||
deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
|
deviceType: userAgent.browser.name || userAgent.device.type || request.get('devicemodel') || '',
|
||||||
deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
|
deviceOS: userAgent.os.name || request.get('devicetype') || '',
|
||||||
|
appVersion,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,7 +93,6 @@ export class AuthGuard implements CanActivate {
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const targets = [context.getHandler()];
|
const targets = [context.getHandler()];
|
||||||
|
|
||||||
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
||||||
if (!options) {
|
if (!options) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -105,6 +111,12 @@ export class AuthGuard implements CanActivate {
|
||||||
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
|
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (request.user?.session) {
|
||||||
|
const userAgent = request.get('user-agent') || '';
|
||||||
|
const appVersion = request.get('x-app-version') || getAppVersionFromUA(userAgent);
|
||||||
|
await this.authService.heartbeat(request.user, appVersion);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,28 @@ select
|
||||||
where
|
where
|
||||||
"user"."id" = "user_metadata"."userId"
|
"user"."id" = "user_metadata"."userId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "metadata"
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
where
|
where
|
||||||
|
|
@ -71,7 +92,28 @@ select
|
||||||
where
|
where
|
||||||
"user"."id" = "user_metadata"."userId"
|
"user"."id" = "user_metadata"."userId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "metadata"
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
where
|
where
|
||||||
|
|
@ -150,7 +192,28 @@ select
|
||||||
where
|
where
|
||||||
"user"."id" = "user_metadata"."userId"
|
"user"."id" = "user_metadata"."userId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "metadata"
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
where
|
where
|
||||||
|
|
@ -175,7 +238,42 @@ select
|
||||||
"shouldChangePassword",
|
"shouldChangePassword",
|
||||||
"storageLabel",
|
"storageLabel",
|
||||||
"quotaSizeInBytes",
|
"quotaSizeInBytes",
|
||||||
"quotaUsageInBytes"
|
"quotaUsageInBytes",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
|
from
|
||||||
|
"user_metadata"
|
||||||
|
where
|
||||||
|
"user"."id" = "user_metadata"."userId"
|
||||||
|
) as agg
|
||||||
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
where
|
where
|
||||||
|
|
@ -214,7 +312,28 @@ select
|
||||||
where
|
where
|
||||||
"user"."id" = "user_metadata"."userId"
|
"user"."id" = "user_metadata"."userId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "metadata"
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
where
|
where
|
||||||
|
|
@ -261,7 +380,28 @@ select
|
||||||
where
|
where
|
||||||
"user"."id" = "user_metadata"."userId"
|
"user"."id" = "user_metadata"."userId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "metadata"
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
order by
|
order by
|
||||||
|
|
@ -299,7 +439,28 @@ select
|
||||||
where
|
where
|
||||||
"user"."id" = "user_metadata"."userId"
|
"user"."id" = "user_metadata"."userId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "metadata"
|
) as "metadata",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"session"."id",
|
||||||
|
"session"."createdAt",
|
||||||
|
"session"."updatedAt",
|
||||||
|
"session"."expiresAt",
|
||||||
|
"session"."deviceOS",
|
||||||
|
"session"."deviceType",
|
||||||
|
"session"."appVersion",
|
||||||
|
"session"."pinExpiresAt",
|
||||||
|
"session"."isPendingSyncReset"
|
||||||
|
from
|
||||||
|
"session"
|
||||||
|
where
|
||||||
|
"session"."userId" = "user"."id"
|
||||||
|
) as agg
|
||||||
|
) as "sessions"
|
||||||
from
|
from
|
||||||
"user"
|
"user"
|
||||||
where
|
where
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,25 @@ const withMetadata = (eb: ExpressionBuilder<DB, 'user'>) => {
|
||||||
).as('metadata');
|
).as('metadata');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const withSessions = (eb: ExpressionBuilder<DB, 'user'>) => {
|
||||||
|
return jsonArrayFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('session')
|
||||||
|
.select([
|
||||||
|
'session.id',
|
||||||
|
'session.createdAt',
|
||||||
|
'session.updatedAt',
|
||||||
|
'session.expiresAt',
|
||||||
|
'session.deviceOS',
|
||||||
|
'session.deviceType',
|
||||||
|
'session.appVersion',
|
||||||
|
'session.pinExpiresAt',
|
||||||
|
'session.isPendingSyncReset',
|
||||||
|
])
|
||||||
|
.whereRef('session.userId', '=', 'user.id'),
|
||||||
|
).as('sessions');
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
@ -52,6 +71,7 @@ export class UserRepository {
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
|
.select(withSessions)
|
||||||
.where('user.id', '=', userId)
|
.where('user.id', '=', userId)
|
||||||
.$if(!options.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
|
.$if(!options.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
@ -66,11 +86,12 @@ export class UserRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
getAdmin() {
|
async getAdmin() {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
|
.select(withSessions)
|
||||||
.where('user.isAdmin', '=', true)
|
.where('user.isAdmin', '=', true)
|
||||||
.where('user.deletedAt', 'is', null)
|
.where('user.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
@ -124,6 +145,7 @@ export class UserRepository {
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
|
.select(withSessions)
|
||||||
.$if(!!options?.withPassword, (eb) => eb.select('password'))
|
.$if(!!options?.withPassword, (eb) => eb.select('password'))
|
||||||
.where('email', '=', email)
|
.where('email', '=', email)
|
||||||
.where('user.deletedAt', 'is', null)
|
.where('user.deletedAt', 'is', null)
|
||||||
|
|
@ -135,6 +157,8 @@ export class UserRepository {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
|
.select(withMetadata)
|
||||||
|
.select(withSessions)
|
||||||
.where('user.storageLabel', '=', storageLabel)
|
.where('user.storageLabel', '=', storageLabel)
|
||||||
.where('user.deletedAt', 'is', null)
|
.where('user.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
@ -146,6 +170,7 @@ export class UserRepository {
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
|
.select(withSessions)
|
||||||
.where('user.oauthId', '=', oauthId)
|
.where('user.oauthId', '=', oauthId)
|
||||||
.where('user.deletedAt', 'is', null)
|
.where('user.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
@ -160,11 +185,12 @@ export class UserRepository {
|
||||||
{ name: 'with deleted', params: [{ withDeleted: true }] },
|
{ name: 'with deleted', params: [{ withDeleted: true }] },
|
||||||
{ name: 'without deleted', params: [{ withDeleted: false }] },
|
{ name: 'without deleted', params: [{ withDeleted: false }] },
|
||||||
)
|
)
|
||||||
getList({ id, withDeleted }: UserListFilter = {}) {
|
async getList({ id, withDeleted }: UserListFilter = {}) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
|
.select(withSessions)
|
||||||
.$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
|
.$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
|
||||||
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
|
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy('createdAt', 'desc')
|
||||||
|
|
@ -177,6 +203,7 @@ export class UserRepository {
|
||||||
.values(dto)
|
.values(dto)
|
||||||
.returning(columns.userAdmin)
|
.returning(columns.userAdmin)
|
||||||
.returning(withMetadata)
|
.returning(withMetadata)
|
||||||
|
.returning(withSessions)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +215,7 @@ export class UserRepository {
|
||||||
.where('user.deletedAt', 'is', null)
|
.where('user.deletedAt', 'is', null)
|
||||||
.returning(columns.userAdmin)
|
.returning(columns.userAdmin)
|
||||||
.returning(withMetadata)
|
.returning(withMetadata)
|
||||||
|
.returning(withSessions)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +230,7 @@ export class UserRepository {
|
||||||
.where('user.id', '=', asUuid(id))
|
.where('user.id', '=', asUuid(id))
|
||||||
.returning(columns.userAdmin)
|
.returning(columns.userAdmin)
|
||||||
.returning(withMetadata)
|
.returning(withMetadata)
|
||||||
|
.returning(withSessions)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "session" ADD "appVersion" character varying NOT NULL DEFAULT '';`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db);
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,9 @@ export class SessionTable {
|
||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
deviceOS!: Generated<string>;
|
deviceOS!: Generated<string>;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
appVersion!: Generated<string>;
|
||||||
|
|
||||||
@UpdateIdColumn({ index: true })
|
@UpdateIdColumn({ index: true })
|
||||||
updateId!: Generated<string>;
|
updateId!: Generated<string>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export interface LoginDetails {
|
||||||
clientIp: string;
|
clientIp: string;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
deviceOS: string;
|
deviceOS: string;
|
||||||
|
appVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimOptions<T> {
|
interface ClaimOptions<T> {
|
||||||
|
|
@ -56,6 +57,18 @@ export type ValidateRequest = {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService extends BaseService {
|
export class AuthService extends BaseService {
|
||||||
|
async heartbeat(auth: AuthDto, appVersion: string): Promise<void> {
|
||||||
|
if (!auth.session) {
|
||||||
|
throw new BadRequestException('No active session');
|
||||||
|
}
|
||||||
|
const updateData: { appVersion?: string; updatedAt: Date } = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (appVersion) {
|
||||||
|
updateData.appVersion = appVersion;
|
||||||
|
}
|
||||||
|
await this.sessionRepository.update(auth.session.id, updateData);
|
||||||
|
}
|
||||||
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
||||||
const config = await this.getConfig({ withCache: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
if (!config.passwordLogin.enabled) {
|
if (!config.passwordLogin.enabled) {
|
||||||
|
|
@ -529,6 +542,7 @@ export class AuthService extends BaseService {
|
||||||
token: tokenHashed,
|
token: tokenHashed,
|
||||||
deviceOS: loginDetails.deviceOS,
|
deviceOS: loginDetails.deviceOS,
|
||||||
deviceType: loginDetails.deviceType,
|
deviceType: loginDetails.deviceType,
|
||||||
|
appVersion: loginDetails.appVersion || '',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
3
server/test/fixtures/user.stub.ts
vendored
3
server/test/fixtures/user.stub.ts
vendored
|
|
@ -20,6 +20,7 @@ export const userStub = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
|
sessions: [],
|
||||||
},
|
},
|
||||||
user1: <UserAdmin>{
|
user1: <UserAdmin>{
|
||||||
...authStub.user1.user,
|
...authStub.user1.user,
|
||||||
|
|
@ -37,6 +38,7 @@ export const userStub = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
|
sessions: [],
|
||||||
},
|
},
|
||||||
user2: <UserAdmin>{
|
user2: <UserAdmin>{
|
||||||
...authStub.user2.user,
|
...authStub.user2.user,
|
||||||
|
|
@ -54,5 +56,6 @@ export const userStub = {
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
|
sessions: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
3
server/test/small.factory.ts
Normal file → Executable file
3
server/test/small.factory.ts
Normal file → Executable file
|
|
@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
||||||
userId: newUuid(),
|
userId: newUuid(),
|
||||||
pinExpiresAt: newDate(),
|
pinExpiresAt: newDate(),
|
||||||
isPendingSyncReset: false,
|
isPendingSyncReset: false,
|
||||||
|
appVersion: session.appVersion ?? '',
|
||||||
...session,
|
...session,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -179,6 +180,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||||
quotaUsageInBytes = 0,
|
quotaUsageInBytes = 0,
|
||||||
status = UserStatus.Active,
|
status = UserStatus.Active,
|
||||||
metadata = [],
|
metadata = [],
|
||||||
|
sessions = [],
|
||||||
} = user;
|
} = user;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -198,6 +200,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||||
quotaUsageInBytes,
|
quotaUsageInBytes,
|
||||||
status,
|
status,
|
||||||
metadata,
|
metadata,
|
||||||
|
sessions,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { user as authUser } from '$lib/stores/user.store';
|
import { user as authUser } from '$lib/stores/user.store';
|
||||||
|
import { websocketStore } from '$lib/stores/websocket';
|
||||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
|
@ -36,12 +37,16 @@
|
||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiAccountOutline,
|
mdiAccountOutline,
|
||||||
|
mdiAndroid,
|
||||||
|
mdiApple,
|
||||||
|
mdiAppsBox,
|
||||||
mdiCameraIris,
|
mdiCameraIris,
|
||||||
mdiChartPie,
|
mdiChartPie,
|
||||||
mdiChartPieOutline,
|
mdiChartPieOutline,
|
||||||
mdiCheckCircle,
|
mdiCheckCircle,
|
||||||
mdiDeleteRestore,
|
mdiDeleteRestore,
|
||||||
mdiFeatureSearchOutline,
|
mdiFeatureSearchOutline,
|
||||||
|
mdiHelp,
|
||||||
mdiLockSmart,
|
mdiLockSmart,
|
||||||
mdiOnepassword,
|
mdiOnepassword,
|
||||||
mdiPencilOutline,
|
mdiPencilOutline,
|
||||||
|
|
@ -64,7 +69,10 @@
|
||||||
const TiB = 1024 ** 4;
|
const TiB = 1024 ** 4;
|
||||||
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
||||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
||||||
|
const { serverVersion } = websocketStore;
|
||||||
|
let formattedServerVersion = $derived(
|
||||||
|
$serverVersion ? `${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
|
||||||
|
);
|
||||||
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
|
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
|
||||||
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
||||||
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
||||||
|
|
@ -350,6 +358,56 @@
|
||||||
{/if}
|
{/if}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card color="secondary">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||||
|
<Icon icon={mdiAppsBox} size="1.5rem" />
|
||||||
|
<CardTitle>Mobile Devices</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div class="px-4 pb-7">
|
||||||
|
<Stack gap={3}>
|
||||||
|
{#if user.mobileDevices && user.mobileDevices.length > 0}
|
||||||
|
{#each user.mobileDevices as device (`${device.deviceType}-${device.deviceOS}-${device.lastSeen}`)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 p-3 rounded-lg bg-gray-50 dark:bg-immich-dark-primary/10 shadow-sm"
|
||||||
|
>
|
||||||
|
<span class="flex items-center justify-center">
|
||||||
|
{#if device.deviceType === 'Android' || device.deviceOS === 'Android'}
|
||||||
|
<Icon icon={mdiAndroid} size="2rem" class="text-green-600" />
|
||||||
|
{:else if device.deviceType === 'iOS' || device.deviceType === 'Apple' || device.deviceType === 'iPhone' || device.deviceType === 'iPad' || device.deviceOS === 'iOS' || device.deviceOS === 'Apple' || device.deviceOS === 'iPhone' || device.deviceOS === 'iPad' || device.deviceOS === 'macOS'}
|
||||||
|
<Icon icon={mdiApple} size="2rem" class="text-gray-700" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon={mdiHelp} size="2rem" class="text-gray-400" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<span class="font-medium text-base">
|
||||||
|
{device.deviceType} <span class="mx-1">•</span>
|
||||||
|
{#if formattedServerVersion != null && device.appVersion < formattedServerVersion}
|
||||||
|
<span style="color: red">v{device.appVersion}</span>
|
||||||
|
{:else if formattedServerVersion != null && device.appVersion > formattedServerVersion}
|
||||||
|
<span style="color: green">v{device.appVersion}</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-green-600 text-xs">(App version is newer than server)</span>
|
||||||
|
{:else}
|
||||||
|
<span style="color: inherit">v{device.appVersion}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500"
|
||||||
|
>Last seen: {new Date(device.lastSeen).toLocaleString()}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">No mobile devices</span>
|
||||||
|
{/if}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@ export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
|
||||||
name: Sync.each(() => faker.person.fullName()),
|
name: Sync.each(() => faker.person.fullName()),
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
avatarColor: UserAvatarColor.Primary,
|
avatarColor: UserAvatarColor.Primary,
|
||||||
|
mobileDevices: [
|
||||||
|
{
|
||||||
|
deviceType: 'SM-S938B',
|
||||||
|
deviceOS: 'Android',
|
||||||
|
appVersion: '1.139.4',
|
||||||
|
lastSeen: '2025-08-28T01:57:37.170Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
createdAt: Sync.each(() => faker.date.recent().toISOString()),
|
createdAt: Sync.each(() => faker.date.recent().toISOString()),
|
||||||
updatedAt: Sync.each(() => faker.date.recent().toISOString()),
|
updatedAt: Sync.each(() => faker.date.recent().toISOString()),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue