From 032de9ff2f9c995c298eb974a6403e604886c761 Mon Sep 17 00:00:00 2001 From: aviv926 <51673860+aviv926@users.noreply.github.com> Date: Wed, 22 Oct 2025 01:36:18 +0300 Subject: [PATCH] feat: view the user's app version on the user page (#21345) Co-authored-by: Daniel Dietzler --- e2e/src/responses.ts | 1 + mobile/openapi/README.md | 1 + mobile/openapi/lib/api/users_admin_api.dart | 56 ++++++++++++++++++ mobile/openapi/lib/model/permission.dart | 3 + .../model/session_create_response_dto.dart | 14 ++++- .../lib/model/session_response_dto.dart | 14 ++++- open-api/immich-openapi-specs.json | 59 +++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 36 +++++++---- .../src/controllers/user-admin.controller.ts | 7 +++ server/src/database.ts | 3 +- server/src/dtos/session.dto.ts | 2 + server/src/dtos/user.dto.ts | 1 + server/src/enum.ts | 2 + server/src/middleware/auth.guard.ts | 10 ++-- server/src/queries/session.repository.sql | 1 + ...1078763279-AddAppVersionColumnToSession.ts | 9 +++ server/src/schema/tables/session.table.ts | 3 + server/src/services/auth.service.spec.ts | 5 ++ server/src/services/auth.service.ts | 18 ++++-- server/src/services/user-admin.service.ts | 6 ++ server/src/utils/request.ts | 17 ++++++ server/test/medium.factory.ts | 2 +- server/test/small.factory.ts | 1 + .../user-settings-page/device-card.svelte | 34 ++++++----- .../user-settings-page/device-list.svelte | 16 ++--- web/src/routes/admin/users/[id]/+page.svelte | 24 +++++++- web/src/routes/admin/users/[id]/+page.ts | 6 +- 27 files changed, 301 insertions(+), 50 deletions(-) create mode 100644 server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index b14aedf895..27e6091206 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -119,5 +119,6 @@ export const deviceDto = { isPendingSyncReset: false, deviceOS: '', deviceType: '', + appVersion: null, }, }; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 698b9774da..7ee04c07b1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -282,6 +282,7 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | *UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | *UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | +*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions | *UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | *UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index e4fc1673ef..4a4301ff43 100644 --- a/mobile/openapi/lib/api/users_admin_api.dart +++ b/mobile/openapi/lib/api/users_admin_api.dart @@ -231,6 +231,62 @@ class UsersAdminApi { return null; } + /// This endpoint is an admin-only route, and requires the `adminSession.read` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getUserSessionsAdminWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/users/{id}/sessions' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint is an admin-only route, and requires the `adminSession.read` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + Future?> getUserSessionsAdmin(String id,) async { + final response = await getUserSessionsAdminWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 95b9a55fba..86011eb835 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -150,6 +150,7 @@ class Permission { static const adminUserPeriodRead = Permission._(r'adminUser.read'); static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); static const adminUserPeriodDelete = Permission._(r'adminUser.delete'); + static const adminSessionPeriodRead = Permission._(r'adminSession.read'); static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll'); /// List of all possible values in this [enum][Permission]. @@ -281,6 +282,7 @@ class Permission { adminUserPeriodRead, adminUserPeriodUpdate, adminUserPeriodDelete, + adminSessionPeriodRead, adminAuthPeriodUnlinkAll, ]; @@ -447,6 +449,7 @@ class PermissionTypeTransformer { case r'adminUser.read': return Permission.adminUserPeriodRead; case r'adminUser.update': return Permission.adminUserPeriodUpdate; case r'adminUser.delete': return Permission.adminUserPeriodDelete; + case r'adminSession.read': return Permission.adminSessionPeriodRead; case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index a4f93e8d9c..e16597f3b5 100644 --- a/mobile/openapi/lib/model/session_create_response_dto.dart +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class SessionCreateResponseDto { /// Returns a new [SessionCreateResponseDto] instance. SessionCreateResponseDto({ + required this.appVersion, required this.createdAt, required this.current, required this.deviceOS, @@ -24,6 +25,8 @@ class SessionCreateResponseDto { required this.updatedAt, }); + String? appVersion; + String createdAt; bool current; @@ -50,6 +53,7 @@ class SessionCreateResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is SessionCreateResponseDto && + other.appVersion == appVersion && other.createdAt == createdAt && other.current == current && other.deviceOS == deviceOS && @@ -63,6 +67,7 @@ class SessionCreateResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (appVersion == null ? 0 : appVersion!.hashCode) + (createdAt.hashCode) + (current.hashCode) + (deviceOS.hashCode) + @@ -74,10 +79,15 @@ class SessionCreateResponseDto { (updatedAt.hashCode); @override - String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]'; + String toString() => 'SessionCreateResponseDto[appVersion=$appVersion, createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]'; Map toJson() { final json = {}; + if (this.appVersion != null) { + json[r'appVersion'] = this.appVersion; + } else { + // json[r'appVersion'] = null; + } json[r'createdAt'] = this.createdAt; json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; @@ -103,6 +113,7 @@ class SessionCreateResponseDto { final json = value.cast(); return SessionCreateResponseDto( + appVersion: mapValueOfType(json, r'appVersion'), createdAt: mapValueOfType(json, r'createdAt')!, current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, @@ -159,6 +170,7 @@ class SessionCreateResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'appVersion', 'createdAt', 'current', 'deviceOS', diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index e76e4d48b4..85acb8a358 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class SessionResponseDto { /// Returns a new [SessionResponseDto] instance. SessionResponseDto({ + required this.appVersion, required this.createdAt, required this.current, required this.deviceOS, @@ -23,6 +24,8 @@ class SessionResponseDto { required this.updatedAt, }); + String? appVersion; + String createdAt; bool current; @@ -47,6 +50,7 @@ class SessionResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is SessionResponseDto && + other.appVersion == appVersion && other.createdAt == createdAt && other.current == current && other.deviceOS == deviceOS && @@ -59,6 +63,7 @@ class SessionResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (appVersion == null ? 0 : appVersion!.hashCode) + (createdAt.hashCode) + (current.hashCode) + (deviceOS.hashCode) + @@ -69,10 +74,15 @@ class SessionResponseDto { (updatedAt.hashCode); @override - String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]'; + String toString() => 'SessionResponseDto[appVersion=$appVersion, createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]'; Map toJson() { final json = {}; + if (this.appVersion != null) { + json[r'appVersion'] = this.appVersion; + } else { + // json[r'appVersion'] = null; + } json[r'createdAt'] = this.createdAt; json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; @@ -97,6 +107,7 @@ class SessionResponseDto { final json = value.cast(); return SessionResponseDto( + appVersion: mapValueOfType(json, r'appVersion'), createdAt: mapValueOfType(json, r'createdAt')!, current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, @@ -152,6 +163,7 @@ class SessionResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'appVersion', 'createdAt', 'current', 'deviceOS', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fdfc40eb6c..3b258d505f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -773,6 +773,54 @@ "description": "This endpoint is an admin-only route, and requires the `adminUser.delete` permission." } }, + "/admin/users/{id}/sessions": { + "get": { + "operationId": "getUserSessionsAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SessionResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users (admin)" + ], + "x-immich-admin-only": true, + "x-immich-permission": "adminSession.read", + "description": "This endpoint is an admin-only route, and requires the `adminSession.read` permission." + } + }, "/admin/users/{id}/statistics": { "get": { "operationId": "getUserStatisticsAdmin", @@ -13267,6 +13315,7 @@ "adminUser.read", "adminUser.update", "adminUser.delete", + "adminSession.read", "adminAuth.unlinkAll" ], "type": "string" @@ -14303,6 +14352,10 @@ }, "SessionCreateResponseDto": { "properties": { + "appVersion": { + "nullable": true, + "type": "string" + }, "createdAt": { "type": "string" }, @@ -14332,6 +14385,7 @@ } }, "required": [ + "appVersion", "createdAt", "current", "deviceOS", @@ -14345,6 +14399,10 @@ }, "SessionResponseDto": { "properties": { + "appVersion": { + "nullable": true, + "type": "string" + }, "createdAt": { "type": "string" }, @@ -14371,6 +14429,7 @@ } }, "required": [ + "appVersion", "createdAt", "current", "deviceOS", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c952c30af..cdd0047701 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -244,6 +244,17 @@ export type UserPreferencesUpdateDto = { sharedLinks?: SharedLinksUpdate; tags?: TagsUpdate; }; +export type SessionResponseDto = { + appVersion: string | null; + createdAt: string; + current: boolean; + deviceOS: string; + deviceType: string; + expiresAt?: string; + id: string; + isPendingSyncReset: boolean; + updatedAt: string; +}; export type AssetStatsResponseDto = { images: number; total: number; @@ -1192,16 +1203,6 @@ export type ServerVersionHistoryResponseDto = { id: string; version: string; }; -export type SessionResponseDto = { - createdAt: string; - current: boolean; - deviceOS: string; - deviceType: string; - expiresAt?: string; - id: string; - isPendingSyncReset: boolean; - updatedAt: string; -}; export type SessionCreateDto = { deviceOS?: string; deviceType?: string; @@ -1209,6 +1210,7 @@ export type SessionCreateDto = { duration?: number; }; export type SessionCreateResponseDto = { + appVersion: string | null; createdAt: string; current: boolean; deviceOS: string; @@ -1853,6 +1855,19 @@ export function restoreUserAdmin({ id }: { method: "POST" })); } +/** + * This endpoint is an admin-only route, and requires the `adminSession.read` permission. + */ +export function getUserSessionsAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SessionResponseDto[]; + }>(`/admin/users/${encodeURIComponent(id)}/sessions`, { + ...opts + })); +} /** * This endpoint is an admin-only route, and requires the `adminUser.read` permission. */ @@ -4830,6 +4845,7 @@ export enum Permission { AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", AdminUserDelete = "adminUser.delete", + AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } export enum AssetMetadataKey { diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index d50bd174ad..25a4691b75 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, import { ApiTags } from '@nestjs/swagger'; import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto } from 'src/dtos/session.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, @@ -58,6 +59,12 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/sessions') + @Authenticated({ permission: Permission.AdminSessionRead, admin: true }) + getUserSessionsAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getSessions(auth, id); + } + @Get(':id/statistics') @Authenticated({ permission: Permission.AdminUserRead, admin: true }) getUserStatisticsAdmin( diff --git a/server/src/database.ts b/server/src/database.ts index f472c643ee..f60c2c228c 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -238,6 +238,7 @@ export type Session = { expiresAt: Date | null; deviceOS: string; deviceType: string; + appVersion: string | null; pinExpiresAt: Date | null; isPendingSyncReset: boolean; }; @@ -308,7 +309,7 @@ export const columns = { assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authApiKey: ['api_key.id', 'api_key.permissions'], - authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt'], + authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], authSharedLink: [ 'shared_link.id', 'shared_link.userId', diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index 7ccc72a5f1..49351eda52 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -34,6 +34,7 @@ export class SessionResponseDto { current!: boolean; deviceType!: string; deviceOS!: string; + appVersion!: string | null; isPendingSyncReset!: boolean; } @@ -47,6 +48,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse updatedAt: entity.updatedAt.toISOString(), expiresAt: entity.expiresAt?.toISOString(), current: currentId === entity.id, + appVersion: entity.appVersion, deviceOS: entity.deviceOS, deviceType: entity.deviceType, isPendingSyncReset: entity.isPendingSyncReset, diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 443178aa10..c5067f3e8d 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -173,6 +173,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const license = metadata.find( (item): item is UserMetadataItem => item.key === UserMetadataKey.License, )?.value; + return { ...mapUser(entity), storageLabel: entity.storageLabel, diff --git a/server/src/enum.ts b/server/src/enum.ts index b8e6e5209f..c056091f22 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -236,6 +236,8 @@ export enum Permission { AdminUserUpdate = 'adminUser.update', AdminUserDelete = 'adminUser.delete', + AdminSessionRead = 'adminSession.read', + AdminAuthUnlinkAll = 'adminAuth.unlinkAll', } diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 8af7bf7fb3..4964fefbbc 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -13,7 +13,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { ApiCustomExtension, ImmichQuery, MetadataKey, Permission } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AuthService, LoginDetails } from 'src/services/auth.service'; -import { UAParser } from 'ua-parser-js'; +import { getUserAgentDetails } from 'src/utils/request'; type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; @@ -56,13 +56,14 @@ export const FileResponse = () => export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => { const request = context.switchToHttp().getRequest(); - const userAgent = UAParser(request.headers['user-agent']); + const { deviceType, deviceOS, appVersion } = getUserAgentDetails(request.headers); 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, + deviceOS, + appVersion, }; }); @@ -86,7 +87,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; diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 34d25cce8a..831a16342a 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -23,6 +23,7 @@ select "session"."id", "session"."updatedAt", "session"."pinExpiresAt", + "session"."appVersion", ( select to_json(obj) diff --git a/server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts b/server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts new file mode 100644 index 0000000000..8175788517 --- /dev/null +++ b/server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.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;`.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..466152d35d 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({ nullable: true }) + appVersion!: string | null; + @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d2b287cd5e..d8d7598593 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -41,6 +41,7 @@ const loginDetails = { clientIp: '127.0.0.1', deviceOS: '', deviceType: '', + appVersion: null, }; const fixtures = { @@ -243,6 +244,7 @@ describe(AuthService.name, () => { updatedAt: session.updatedAt, user: factory.authUser(), pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -408,6 +410,7 @@ describe(AuthService.name, () => { updatedAt: session.updatedAt, user: factory.authUser(), pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -435,6 +438,7 @@ describe(AuthService.name, () => { user: factory.authUser(), isPendingSyncReset: false, pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -456,6 +460,7 @@ describe(AuthService.name, () => { user: factory.authUser(), isPendingSyncReset: false, pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 535df779cd..d118f1809a 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -29,11 +29,13 @@ import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; +import { getUserAgentDetails } from 'src/utils/request'; export interface LoginDetails { isSecure: boolean; clientIp: string; deviceType: string; deviceOS: string; + appVersion: string | null; } interface ClaimOptions { @@ -218,7 +220,7 @@ export class AuthService extends BaseService { } if (session) { - return this.validateSession(session); + return this.validateSession(session, headers); } if (apiKey) { @@ -463,15 +465,22 @@ export class AuthService extends BaseService { return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); } - private async validateSession(tokenValue: string): Promise { + private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const session = await this.sessionRepository.getByToken(hashedToken); if (session?.user) { + const { appVersion, deviceOS, deviceType } = getUserAgentDetails(headers); const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); - if (diff.hours > 1) { - await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); + if (diff.hours > 1 || appVersion != session.appVersion) { + await this.sessionRepository.update(session.id, { + id: session.id, + updatedAt: new Date(), + appVersion, + deviceOS, + deviceType, + }); } // Pin check @@ -529,6 +538,7 @@ export class AuthService extends BaseService { token: tokenHashed, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, + appVersion: loginDetails.appVersion, userId: user.id, }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index a57072e496..2684dca0c9 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com import { SALT_ROUNDS } from 'src/constants'; import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, @@ -119,6 +120,11 @@ export class UserAdminService extends BaseService { return mapUserAdmin(user); } + async getSessions(auth: AuthDto, id: string): Promise { + const sessions = await this.sessionRepository.getByUserId(id); + return sessions.map((session) => mapSession(session)); + } + async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise { const stats = await this.assetRepository.getStatistics(id, dto); return mapStats(stats); diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index 19d3cac661..c64c980520 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -1,5 +1,22 @@ +import { IncomingHttpHeaders } from 'node:http'; +import { UAParser } from 'ua-parser-js'; + export const fromChecksum = (checksum: string): Buffer => { return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); }; export const fromMaybeArray = (param: T | T[]) => (Array.isArray(param) ? param[0] : param); + +const getAppVersionFromUA = (ua: string) => + ua.match(/^Immich_(?:Android|iOS)_(?.+)$/)?.groups?.appVersion ?? null; + +export const getUserAgentDetails = (headers: IncomingHttpHeaders) => { + const userAgent = UAParser(headers['user-agent']); + const appVersion = getAppVersionFromUA(headers['user-agent'] ?? ''); + + return { + deviceType: userAgent.browser.name || userAgent.device.type || (headers['devicemodel'] as string) || '', + deviceOS: userAgent.os.name || (headers['devicetype'] as string) || '', + appVersion, + }; +}; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 3f021f3eb7..a8d3f9df78 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -628,7 +628,7 @@ const syncStream = () => { }; const loginDetails = () => { - return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' }; + return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '', appVersion: null }; }; const loginResponse = (): LoginResponseDto => { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 04654552a3..09e7988f8f 100644 --- 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 ?? null, ...session, }); diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 15d9ede219..f3156b7e7d 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -18,11 +18,11 @@ import { t } from 'svelte-i18n'; interface Props { - device: SessionResponseDto; + session: SessionResponseDto; onDelete?: (() => void) | undefined; } - let { device, onDelete = undefined }: Props = $props(); + const { session, onDelete = undefined }: Props = $props(); const options: ToRelativeCalendarOptions = { unit: 'days', @@ -32,21 +32,21 @@