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)
|
||||
- [MergePersonDto](doc//MergePersonDto.md)
|
||||
- [MetadataSearchDto](doc//MetadataSearchDto.md)
|
||||
- [MobileDeviceDto](doc//MobileDeviceDto.md)
|
||||
- [NotificationCreateDto](doc//NotificationCreateDto.md)
|
||||
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.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/merge_person_dto.dart';
|
||||
part 'model/metadata_search_dto.dart';
|
||||
part 'model/mobile_device_dto.dart';
|
||||
part 'model/notification_create_dto.dart';
|
||||
part 'model/notification_delete_all_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);
|
||||
case 'MetadataSearchDto':
|
||||
return MetadataSearchDto.fromJson(value);
|
||||
case 'MobileDeviceDto':
|
||||
return MobileDeviceDto.fromJson(value);
|
||||
case 'NotificationCreateDto':
|
||||
return NotificationCreateDto.fromJson(value);
|
||||
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.isAdmin,
|
||||
required this.license,
|
||||
this.mobileDevices = const [],
|
||||
required this.name,
|
||||
required this.oauthId,
|
||||
required this.profileChangedAt,
|
||||
|
|
@ -46,6 +47,8 @@ class UserAdminResponseDto {
|
|||
|
||||
UserLicense? license;
|
||||
|
||||
List<MobileDeviceDto> mobileDevices;
|
||||
|
||||
String name;
|
||||
|
||||
String oauthId;
|
||||
|
|
@ -75,6 +78,7 @@ class UserAdminResponseDto {
|
|||
other.id == id &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.license == license &&
|
||||
_deepEquality.equals(other.mobileDevices, mobileDevices) &&
|
||||
other.name == name &&
|
||||
other.oauthId == oauthId &&
|
||||
other.profileChangedAt == profileChangedAt &&
|
||||
|
|
@ -96,6 +100,7 @@ class UserAdminResponseDto {
|
|||
(id.hashCode) +
|
||||
(isAdmin.hashCode) +
|
||||
(license == null ? 0 : license!.hashCode) +
|
||||
(mobileDevices.hashCode) +
|
||||
(name.hashCode) +
|
||||
(oauthId.hashCode) +
|
||||
(profileChangedAt.hashCode) +
|
||||
|
|
@ -108,7 +113,7 @@ class UserAdminResponseDto {
|
|||
(updatedAt.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
|
|
@ -127,6 +132,7 @@ class UserAdminResponseDto {
|
|||
} else {
|
||||
// json[r'license'] = null;
|
||||
}
|
||||
json[r'mobileDevices'] = this.mobileDevices;
|
||||
json[r'name'] = this.name;
|
||||
json[r'oauthId'] = this.oauthId;
|
||||
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
|
||||
|
|
@ -168,6 +174,7 @@ class UserAdminResponseDto {
|
|||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
|
||||
license: UserLicense.fromJson(json[r'license']),
|
||||
mobileDevices: MobileDeviceDto.listFromJson(json[r'mobileDevices']),
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
oauthId: mapValueOfType<String>(json, r'oauthId')!,
|
||||
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
|
||||
|
|
@ -232,6 +239,7 @@ class UserAdminResponseDto {
|
|||
'id',
|
||||
'isAdmin',
|
||||
'license',
|
||||
'mobileDevices',
|
||||
'name',
|
||||
'oauthId',
|
||||
'profileChangedAt',
|
||||
|
|
|
|||
|
|
@ -12724,6 +12724,30 @@
|
|||
},
|
||||
"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": {
|
||||
"properties": {
|
||||
"data": {
|
||||
|
|
@ -17523,6 +17547,12 @@
|
|||
],
|
||||
"nullable": true
|
||||
},
|
||||
"mobileDevices": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MobileDeviceDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -17573,6 +17603,7 @@
|
|||
"id",
|
||||
"isAdmin",
|
||||
"license",
|
||||
"mobileDevices",
|
||||
"name",
|
||||
"oauthId",
|
||||
"profileChangedAt",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@ export type UserLicense = {
|
|||
activationKey: string;
|
||||
licenseKey: string;
|
||||
};
|
||||
export type MobileDeviceDto = {
|
||||
appVersion: string;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
lastSeen: string;
|
||||
};
|
||||
export type UserAdminResponseDto = {
|
||||
avatarColor: UserAvatarColor;
|
||||
createdAt: string;
|
||||
|
|
@ -95,6 +101,7 @@ export type UserAdminResponseDto = {
|
|||
id: string;
|
||||
isAdmin: boolean;
|
||||
license: (UserLicense) | null;
|
||||
mobileDevices: MobileDeviceDto[];
|
||||
name: string;
|
||||
oauthId: string;
|
||||
profileChangedAt: string;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export type UserAdmin = User & {
|
|||
quotaUsageInBytes: number;
|
||||
status: UserStatus;
|
||||
metadata: UserMetadataItem[];
|
||||
sessions: Session[];
|
||||
};
|
||||
|
||||
export type StorageAsset = {
|
||||
|
|
@ -238,6 +239,7 @@ export type Session = {
|
|||
expiresAt: Date | null;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
appVersion: string;
|
||||
pinExpiresAt: Date | null;
|
||||
isPendingSyncReset: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
|||
import { UserMetadataItem } from 'src/types';
|
||||
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 {
|
||||
@Optional()
|
||||
@IsEmail({ require_tld: false })
|
||||
|
|
@ -152,6 +166,8 @@ export class UserAdminDeleteDto {
|
|||
}
|
||||
|
||||
export class UserAdminResponseDto extends UserResponseDto {
|
||||
@ApiProperty({ type: () => [MobileDeviceDto] })
|
||||
mobileDevices!: MobileDeviceDto[];
|
||||
storageLabel!: string | null;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
|
|
@ -173,6 +189,15 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
|||
const license = metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
||||
)?.value;
|
||||
const mobileDevices = (entity.sessions || [])
|
||||
.filter(({ appVersion }) => appVersion)
|
||||
.map(({ deviceType, deviceOS, appVersion, updatedAt }) => ({
|
||||
deviceType,
|
||||
deviceOS,
|
||||
appVersion,
|
||||
lastSeen: updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
...mapUser(entity),
|
||||
storageLabel: entity.storageLabel,
|
||||
|
|
@ -186,5 +211,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
|||
quotaUsageInBytes: entity.quotaUsageInBytes,
|
||||
status: entity.status,
|
||||
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' } } },
|
||||
});
|
||||
|
||||
const getAppVersionFromUA = (ua: string) =>
|
||||
ua.match(/^Immich_(?:Android|iOS)_(?<appVersion>.+)$/)?.groups?.appVersion ?? '';
|
||||
|
||||
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
|
||||
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 {
|
||||
clientIp: request.ip ?? '',
|
||||
isSecure: request.secure,
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
|
||||
deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || request.get('devicemodel') || '',
|
||||
deviceOS: userAgent.os.name || request.get('devicetype') || '',
|
||||
appVersion,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -86,7 +93,6 @@ export class AuthGuard implements CanActivate {
|
|||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler()];
|
||||
|
||||
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
||||
if (!options) {
|
||||
return true;
|
||||
|
|
@ -105,6 +111,12 @@ export class AuthGuard implements CanActivate {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,28 @@ select
|
|||
where
|
||||
"user"."id" = "user_metadata"."userId"
|
||||
) 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
|
||||
"user"
|
||||
where
|
||||
|
|
@ -71,7 +92,28 @@ select
|
|||
where
|
||||
"user"."id" = "user_metadata"."userId"
|
||||
) 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
|
||||
"user"
|
||||
where
|
||||
|
|
@ -150,7 +192,28 @@ select
|
|||
where
|
||||
"user"."id" = "user_metadata"."userId"
|
||||
) 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
|
||||
"user"
|
||||
where
|
||||
|
|
@ -175,7 +238,42 @@ select
|
|||
"shouldChangePassword",
|
||||
"storageLabel",
|
||||
"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
|
||||
"user"
|
||||
where
|
||||
|
|
@ -214,7 +312,28 @@ select
|
|||
where
|
||||
"user"."id" = "user_metadata"."userId"
|
||||
) 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
|
||||
"user"
|
||||
where
|
||||
|
|
@ -261,7 +380,28 @@ select
|
|||
where
|
||||
"user"."id" = "user_metadata"."userId"
|
||||
) 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
|
||||
"user"
|
||||
order by
|
||||
|
|
@ -299,7 +439,28 @@ select
|
|||
where
|
||||
"user"."id" = "user_metadata"."userId"
|
||||
) 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
|
||||
"user"
|
||||
where
|
||||
|
|
|
|||
|
|
@ -40,6 +40,25 @@ const withMetadata = (eb: ExpressionBuilder<DB, 'user'>) => {
|
|||
).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()
|
||||
export class UserRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
|
@ -52,6 +71,7 @@ export class UserRepository {
|
|||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.select(withSessions)
|
||||
.where('user.id', '=', userId)
|
||||
.$if(!options.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
|
||||
.executeTakeFirst();
|
||||
|
|
@ -66,11 +86,12 @@ export class UserRepository {
|
|||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAdmin() {
|
||||
async getAdmin() {
|
||||
return this.db
|
||||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.select(withSessions)
|
||||
.where('user.isAdmin', '=', true)
|
||||
.where('user.deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
|
@ -124,6 +145,7 @@ export class UserRepository {
|
|||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.select(withSessions)
|
||||
.$if(!!options?.withPassword, (eb) => eb.select('password'))
|
||||
.where('email', '=', email)
|
||||
.where('user.deletedAt', 'is', null)
|
||||
|
|
@ -135,6 +157,8 @@ export class UserRepository {
|
|||
return this.db
|
||||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.select(withSessions)
|
||||
.where('user.storageLabel', '=', storageLabel)
|
||||
.where('user.deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
|
@ -146,6 +170,7 @@ export class UserRepository {
|
|||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.select(withSessions)
|
||||
.where('user.oauthId', '=', oauthId)
|
||||
.where('user.deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
|
@ -160,11 +185,12 @@ export class UserRepository {
|
|||
{ name: 'with deleted', params: [{ withDeleted: true }] },
|
||||
{ name: 'without deleted', params: [{ withDeleted: false }] },
|
||||
)
|
||||
getList({ id, withDeleted }: UserListFilter = {}) {
|
||||
async getList({ id, withDeleted }: UserListFilter = {}) {
|
||||
return this.db
|
||||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.select(withSessions)
|
||||
.$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
|
||||
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
|
||||
.orderBy('createdAt', 'desc')
|
||||
|
|
@ -177,6 +203,7 @@ export class UserRepository {
|
|||
.values(dto)
|
||||
.returning(columns.userAdmin)
|
||||
.returning(withMetadata)
|
||||
.returning(withSessions)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +215,7 @@ export class UserRepository {
|
|||
.where('user.deletedAt', 'is', null)
|
||||
.returning(columns.userAdmin)
|
||||
.returning(withMetadata)
|
||||
.returning(withSessions)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +230,7 @@ export class UserRepository {
|
|||
.where('user.id', '=', asUuid(id))
|
||||
.returning(columns.userAdmin)
|
||||
.returning(withMetadata)
|
||||
.returning(withSessions)
|
||||
.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: '' })
|
||||
deviceOS!: Generated<string>;
|
||||
|
||||
@Column({ default: '' })
|
||||
appVersion!: Generated<string>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface LoginDetails {
|
|||
clientIp: string;
|
||||
deviceType: string;
|
||||
deviceOS: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
interface ClaimOptions<T> {
|
||||
|
|
@ -56,6 +57,18 @@ export type ValidateRequest = {
|
|||
|
||||
@Injectable()
|
||||
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) {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
if (!config.passwordLogin.enabled) {
|
||||
|
|
@ -529,6 +542,7 @@ export class AuthService extends BaseService {
|
|||
token: tokenHashed,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
appVersion: loginDetails.appVersion || '',
|
||||
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: [],
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
sessions: [],
|
||||
},
|
||||
user1: <UserAdmin>{
|
||||
...authStub.user1.user,
|
||||
|
|
@ -37,6 +38,7 @@ export const userStub = {
|
|||
metadata: [],
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
sessions: [],
|
||||
},
|
||||
user2: <UserAdmin>{
|
||||
...authStub.user2.user,
|
||||
|
|
@ -54,5 +56,6 @@ export const userStub = {
|
|||
updatedAt: new Date('2021-01-01'),
|
||||
quotaSizeInBytes: null,
|
||||
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(),
|
||||
pinExpiresAt: newDate(),
|
||||
isPendingSyncReset: false,
|
||||
appVersion: session.appVersion ?? '',
|
||||
...session,
|
||||
});
|
||||
|
||||
|
|
@ -179,6 +180,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
|||
quotaUsageInBytes = 0,
|
||||
status = UserStatus.Active,
|
||||
metadata = [],
|
||||
sessions = [],
|
||||
} = user;
|
||||
return {
|
||||
id,
|
||||
|
|
@ -198,6 +200,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
|||
quotaUsageInBytes,
|
||||
status,
|
||||
metadata,
|
||||
sessions,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
|
@ -36,12 +37,16 @@
|
|||
} from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiAndroid,
|
||||
mdiApple,
|
||||
mdiAppsBox,
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
mdiCheckCircle,
|
||||
mdiDeleteRestore,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiHelp,
|
||||
mdiLockSmart,
|
||||
mdiOnepassword,
|
||||
mdiPencilOutline,
|
||||
|
|
@ -64,7 +69,10 @@
|
|||
const TiB = 1024 ** 4;
|
||||
const usage = $derived(user.quotaUsageInBytes ?? 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 availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
||||
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
||||
|
|
@ -350,6 +358,56 @@
|
|||
{/if}
|
||||
</CardBody>
|
||||
</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>
|
||||
</Container>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
|
|||
name: Sync.each(() => faker.person.fullName()),
|
||||
profileImagePath: '',
|
||||
avatarColor: UserAvatarColor.Primary,
|
||||
mobileDevices: [
|
||||
{
|
||||
deviceType: 'SM-S938B',
|
||||
deviceOS: 'Android',
|
||||
appVersion: '1.139.4',
|
||||
lastSeen: '2025-08-28T01:57:37.170Z',
|
||||
},
|
||||
],
|
||||
isAdmin: true,
|
||||
createdAt: Sync.each(() => faker.date.recent().toISOString()),
|
||||
updatedAt: Sync.each(() => faker.date.recent().toISOString()),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue