diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 698b9774da..d29eb5fd5b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a3117ede86..a9db7938a3 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 33056cf14e..df810760ff 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -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': diff --git a/mobile/openapi/lib/model/mobile_device_dto.dart b/mobile/openapi/lib/model/mobile_device_dto.dart new file mode 100644 index 0000000000..5c3c9fcd3b --- /dev/null +++ b/mobile/openapi/lib/model/mobile_device_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return MobileDeviceDto( + appVersion: mapValueOfType(json, r'appVersion')!, + deviceOS: mapValueOfType(json, r'deviceOS')!, + deviceType: mapValueOfType(json, r'deviceType')!, + lastSeen: mapDateTime(json, r'lastSeen', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'appVersion', + 'deviceOS', + 'deviceType', + 'lastSeen', + }; +} + diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index e5ae8e1d4e..bbd777b230 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -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 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 toJson() { final json = {}; @@ -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(json, r'id')!, isAdmin: mapValueOfType(json, r'isAdmin')!, license: UserLicense.fromJson(json[r'license']), + mobileDevices: MobileDeviceDto.listFromJson(json[r'mobileDevices']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, @@ -232,6 +239,7 @@ class UserAdminResponseDto { 'id', 'isAdmin', 'license', + 'mobileDevices', 'name', 'oauthId', 'profileChangedAt', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b9da330ee5..a8b6f2bad3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 60d72fb32b..d06afac5b1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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; diff --git a/server/src/database.ts b/server/src/database.ts index f472c643ee..7c25187f56 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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; }; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 443178aa10..a9d9b81b17 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -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 => 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, }; } diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 8af7bf7fb3..e3b7e88ca8 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -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)_(?.+)$/)?.groups?.appVersion ?? ''; + export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => { const request = context.switchToHttp().getRequest(); - 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 { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(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; } } diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c5a4f139a7..6fe72240a5 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -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 diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 20b41c80f8..f6b93e3eb4 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -40,6 +40,25 @@ const withMetadata = (eb: ExpressionBuilder) => { ).as('metadata'); }; +const withSessions = (eb: ExpressionBuilder) => { + 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) {} @@ -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(); } diff --git a/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts b/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts new file mode 100644 index 0000000000..4ea4ec85ec --- /dev/null +++ b/server/src/schema/migrations/1756340577214-AddAppVersionToSession.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "session" ADD "appVersion" character varying NOT NULL DEFAULT '';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 706abdf887..518582527c 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -42,6 +42,9 @@ export class SessionTable { @Column({ default: '' }) deviceOS!: Generated; + @Column({ default: '' }) + appVersion!: Generated; + @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 535df779cd..811604616d 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -34,6 +34,7 @@ export interface LoginDetails { clientIp: string; deviceType: string; deviceOS: string; + appVersion?: string; } interface ClaimOptions { @@ -56,6 +57,18 @@ export type ValidateRequest = { @Injectable() export class AuthService extends BaseService { + async heartbeat(auth: AuthDto, appVersion: string): Promise { + 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, }); diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 807da5197f..3e6c9eb4f3 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -20,6 +20,7 @@ export const userStub = { metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, + sessions: [], }, user1: { ...authStub.user1.user, @@ -37,6 +38,7 @@ export const userStub = { metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, + sessions: [], }, user2: { ...authStub.user2.user, @@ -54,5 +56,6 @@ export const userStub = { updatedAt: new Date('2021-01-01'), quotaSizeInBytes: null, quotaUsageInBytes: 0, + sessions: [], }, }; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts old mode 100644 new mode 100755 index 04654552a3..2cde5d22b9 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -135,6 +135,7 @@ const sessionFactory = (session: Partial = {}) => ({ userId: newUuid(), pinExpiresAt: newDate(), isPendingSyncReset: false, + appVersion: session.appVersion ?? '', ...session, }); @@ -179,6 +180,7 @@ const userAdminFactory = (user: Partial = {}) => { quotaUsageInBytes = 0, status = UserStatus.Active, metadata = [], + sessions = [], } = user; return { id, @@ -198,6 +200,7 @@ const userAdminFactory = (user: Partial = {}) => { quotaUsageInBytes, status, metadata, + sessions, }; }; diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index c51c311196..a5ef0327f4 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -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} + + +
+ + Mobile Devices +
+
+ +
+ + {#if user.mobileDevices && user.mobileDevices.length > 0} + {#each user.mobileDevices as device (`${device.deviceType}-${device.deviceOS}-${device.lastSeen}`)} +
+ + {#if device.deviceType === 'Android' || device.deviceOS === 'Android'} + + {: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'} + + {:else} + + {/if} + +
+ + {device.deviceType} + {#if formattedServerVersion != null && device.appVersion < formattedServerVersion} + v{device.appVersion} + {:else if formattedServerVersion != null && device.appVersion > formattedServerVersion} + v{device.appVersion} +
+ (App version is newer than server) + {:else} + v{device.appVersion} + {/if} +
+ Last seen: {new Date(device.lastSeen).toLocaleString()} +
+
+ {/each} + {:else} + No mobile devices + {/if} +
+
+
+
diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 92d1510d40..536a7ff223 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -17,6 +17,14 @@ export const userAdminFactory = Sync.makeFactory({ 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()),