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 | Bin 41011 -> 41140 bytes mobile/openapi/lib/api/users_admin_api.dart | Bin 18328 -> 20189 bytes mobile/openapi/lib/model/permission.dart | Bin 24034 -> 24215 bytes .../model/session_create_response_dto.dart | Bin 5472 -> 5859 bytes .../lib/model/session_response_dto.dart | Bin 5147 -> 5534 bytes 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, 215 insertions(+), 48 deletions(-) create mode 100644 server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index b14aedf89..27e609120 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 698b9774da4810e279ac7d29b597f8838ca6910c..7ee04c07b16c523e3c830112e8f2bc7d56b7e4c2 100644 GIT binary patch delta 61 zcmdmdfN9G?rVYPh*;0#(GxPH%-;Y<|Pfsl=Elw>eh6oi;){Rx-(1-FS$Hz%cie=yY IGuFxp05?t9!);LEw}lVk{~0J{$QY9b}gUG;u0IE&Y;xdg8aPVRF{%`J9|3?RAY-L9}uyD uYoF*KyqQn*A>-sCvnn=-_Q_w&)L~3z^XZdR`o delta 21 dcmcaRmvKfvBH#c3 delta 28 kcmbQfm+{eV#tpMnC#M?=Z~mzo#Jf4&c&6rN-&ik30Jqi)iU0rr diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index a4f93e8d9cd537eb38516fef57e8e46bf41a2ee6..e16597f3b5d3b18381e766d94932e78f49fcf139 100644 GIT binary patch delta 319 zcmaE$^;mbqDn`!4f`YKrqT zpvvZz=Hw{YD;OwPAt_YU%SbHFaL!Lj)l|^lJcrGV5y?zj6@&>o3Y#CX^D=s6rYUHY zWMmfWA*2)(Z52RK1+e)2|MMP39~T~lXsE>8{z50a41u Dw7L-z diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index e76e4d48b4d9dd3d7c70f834c8f6fdfe4ae7b286..85acb8a358215b1db383ed3817d39569e6f590c4 100644 GIT binary patch delta 312 zcmbQOF;9EL3`VZRf`YKrqTyv<{;fDqiA$yC84f#56H+9JzdW+`Me zK$Xoa&B;-)S1?epLQ<%xmyuYU;hdk6s;Qv8*^W(<5y?zj6@&>o3X{{>{|jfPDQJ{r zWESfoWF{+cO1dGq>ahy83TR5KxfFn)Rv|T~I8~t<%+c3ZP{=CI&x(`/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 d50bd174a..25a4691b7 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 f472c643e..f60c2c228 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 7ccc72a5f..49351eda5 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 443178aa1..c5067f3e8 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 b8e6e5209..c056091f2 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 8af7bf7fb..4964fefbb 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 34d25cce8..831a16342 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 000000000..817578851 --- /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 706abdf88..466152d35 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 d2b287cd5..d8d759859 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 535df779c..d118f1809 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 a57072e49..2684dca0c 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 19d3cac66..c64c98052 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 3f021f3eb..a8d3f9df7 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 04654552a..09e7988f8 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 15d9ede21..f3156b7e7 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 @@