diff --git a/i18n/en.json b/i18n/en.json index f061cb1450fa7..490bd4de8d8a4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -460,6 +460,7 @@ "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "app_settings": "App Settings", + "app_version": "App version", "appears_in": "Appears in", "archive": "Archive", "archive_action_prompt": "{count} added to Archive", diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index e5ae8e1d4ef27..a7da0da2c2c38 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserAdminResponseDto { /// Returns a new [UserAdminResponseDto] instance. UserAdminResponseDto({ + this.appVersion, required this.avatarColor, required this.createdAt, required this.deletedAt, @@ -32,6 +33,8 @@ class UserAdminResponseDto { required this.updatedAt, }); + String? appVersion; + UserAvatarColor avatarColor; DateTime createdAt; @@ -68,6 +71,7 @@ class UserAdminResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is UserAdminResponseDto && + other.appVersion == appVersion && other.avatarColor == avatarColor && other.createdAt == createdAt && other.deletedAt == deletedAt && @@ -89,6 +93,7 @@ class UserAdminResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (appVersion == null ? 0 : appVersion!.hashCode) + (avatarColor.hashCode) + (createdAt.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) + @@ -108,10 +113,15 @@ 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[appVersion=$appVersion, 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]'; Map toJson() { final json = {}; + if (this.appVersion != null) { + json[r'appVersion'] = this.appVersion; + } else { + // json[r'appVersion'] = null; + } json[r'avatarColor'] = this.avatarColor; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); if (this.deletedAt != null) { @@ -161,6 +171,7 @@ class UserAdminResponseDto { final json = value.cast(); return UserAdminResponseDto( + appVersion: mapValueOfType(json, r'appVersion'), avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, createdAt: mapDateTime(json, r'createdAt', r'')!, deletedAt: mapDateTime(json, r'deletedAt', r''), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f00f9082e2032..cd93d6a3fde0c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -17035,6 +17035,10 @@ }, "UserAdminResponseDto": { "properties": { + "appVersion": { + "nullable": true, + "type": "string" + }, "avatarColor": { "allOf": [ { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5cd59c9d3cf7d..9818e1b7b6683 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -88,6 +88,7 @@ export type UserLicense = { licenseKey: string; }; export type UserAdminResponseDto = { + appVersion?: string | null; avatarColor: UserAvatarColor; createdAt: string; deletedAt: string | null; diff --git a/server/src/database.ts b/server/src/database.ts index f472c643ee37f..83274a0a9d473 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -23,6 +23,7 @@ export type AuthUser = { email: string; quotaUsageInBytes: number; quotaSizeInBytes: number | null; + appVersion: string | null; }; export type AlbumUser = { @@ -142,6 +143,7 @@ export type UserAdmin = User & { quotaUsageInBytes: number; status: UserStatus; metadata: UserMetadataItem[]; + appVersion: string | null; }; export type StorageAsset = { @@ -306,7 +308,15 @@ export const columns = { 'asset.type', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], - authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], + authUser: [ + 'user.id', + 'user.name', + 'user.email', + 'user.isAdmin', + 'user.quotaUsageInBytes', + 'user.quotaSizeInBytes', + 'user.appVersion', + ], authApiKey: ['api_key.id', 'api_key.permissions'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt'], authSharedLink: [ @@ -333,6 +343,7 @@ export const columns = { 'storageLabel', 'quotaSizeInBytes', 'quotaUsageInBytes', + 'appVersion', ], tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 0da86bfcb5c24..c91f24b406cf7 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -159,6 +159,8 @@ export class UserAdminResponseDto extends UserResponseDto { deletedAt!: Date | null; updatedAt!: Date; oauthId!: string; + @ApiProperty({ type: 'string', required: false, nullable: true }) + appVersion!: string | null; @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes!: number | null; @ApiProperty({ type: 'integer', format: 'int64' }) @@ -182,6 +184,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { deletedAt: entity.deletedAt, updatedAt: entity.updatedAt, oauthId: entity.oauthId, + appVersion: entity.appVersion ?? null, quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index 43f3155ab8d5c..ef68cad118614 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -15,7 +15,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."appVersion" from "user" where diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 34d25cce8ab88..91eee10452063 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -34,7 +34,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."appVersion" from "user" where diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 0e13b98b5d550..ffa9b157abf65 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -177,7 +177,8 @@ select "user"."email", "user"."isAdmin", "user"."quotaUsageInBytes", - "user"."quotaSizeInBytes" + "user"."quotaSizeInBytes", + "user"."appVersion" from "user" where diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 6a0265478141a..bf9d3cdfcd2cc 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -19,6 +19,7 @@ select "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", + "appVersion", ( select coalesce(json_agg(agg), '[]') @@ -58,6 +59,7 @@ select "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", + "appVersion", ( select coalesce(json_agg(agg), '[]') @@ -137,6 +139,7 @@ select "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", + "appVersion", ( select coalesce(json_agg(agg), '[]') @@ -175,7 +178,8 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + "appVersion" from "user" where @@ -201,6 +205,7 @@ select "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", + "appVersion", ( select coalesce(json_agg(agg), '[]') @@ -248,6 +253,7 @@ select "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", + "appVersion", ( select coalesce(json_agg(agg), '[]') @@ -286,6 +292,7 @@ select "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", + "appVersion", ( select coalesce(json_agg(agg), '[]') diff --git a/server/src/schema/migrations/1753574841837-AddAppversionColumn1753573198596.ts b/server/src/schema/migrations/1753574841837-AddAppversionColumn1753573198596.ts new file mode 100644 index 0000000000000..0b0e5b4ba9247 --- /dev/null +++ b/server/src/schema/migrations/1753574841837-AddAppversionColumn1753573198596.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "user" ADD "appVersion" character varying;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "user" DROP COLUMN "appVersion";`.execute(db); +} diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 46d665638277e..1df0a2a638452 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -81,4 +81,7 @@ export class UserTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @Column({ type: 'character varying', nullable: true, default: null }) + appVersion!: string | null; } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 69d872e8c9a53..445068d345631 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -178,6 +178,18 @@ export class AuthService extends BaseService { const { adminRoute, sharedLinkRoute, uri } = metadata; const requestedPermission = metadata.permission ?? Permission.All; + if (authDto.user && headers['user-agent']) { + const userAgent = Array.isArray(headers['user-agent']) ? headers['user-agent'][0] : headers['user-agent']; + const match = /^Immich_(?Android|iOS)_(?.+)$/.exec(userAgent); + if (match && match.groups) { + const { version: appVersion } = match.groups; + if (authDto.user.appVersion !== appVersion) { + await this.userRepository.update(authDto.user.id, { appVersion }); + authDto.user.appVersion = appVersion; + } + } + } + if (!authDto.user.isAdmin && adminRoute) { this.logger.warn(`Denied access to admin only route: ${uri}`); throw new ForbiddenException('Forbidden'); diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 3e5825c0cc34b..b219fad70b22a 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -9,6 +9,7 @@ const authUser = { isAdmin: true, quotaSizeInBytes: null, quotaUsageInBytes: 0, + appVersion: '1.136.0', }, user1: { id: 'user-id', @@ -17,6 +18,7 @@ const authUser = { isAdmin: false, quotaSizeInBytes: null, quotaUsageInBytes: 0, + appVersion: '1.136.0', }, }; @@ -36,6 +38,7 @@ export const authStub = { isAdmin: false, quotaSizeInBytes: null, quotaUsageInBytes: 0, + appVersion: '1.136.0', }, session: { id: 'token-id', diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 8b44b6eddc795..48ae9835866b0 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -99,9 +99,10 @@ const authUserFactory = (authUser: Partial = {}) => { email = 'test@immich.cloud', quotaUsageInBytes = 0, quotaSizeInBytes = null, + appVersion = '1.0.0', } = authUser; - return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; + return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes, appVersion }; }; const partnerFactory = (partner: Partial = {}) => { @@ -179,6 +180,7 @@ const userAdminFactory = (user: Partial = {}) => { quotaUsageInBytes = 0, status = UserStatus.Active, metadata = [], + appVersion = '1.0.0', } = user; return { id, @@ -198,6 +200,7 @@ const userAdminFactory = (user: Partial = {}) => { quotaUsageInBytes, status, metadata, + appVersion, }; }; diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 4ed91147da42f..4f5f9e51d58f8 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -13,7 +13,7 @@ import { locale } from '$lib/stores/preferences.store'; import { serverConfig } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; - import { websocketEvents } from '$lib/stores/websocket'; + import { websocketEvents, websocketStore } from '$lib/stores/websocket'; import { getByteUnitString } from '$lib/utils/byte-units'; import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { Button, HStack, IconButton, Text, modalManager } from '@immich/ui'; @@ -31,6 +31,11 @@ let allUsers: UserAdminResponseDto[] = $state([]); + const { serverVersion } = websocketStore; + let formattedServerVersion = $derived( + $serverVersion ? `${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null, + ); + const refresh = async () => { allUsers = await searchUsersAdmin({ withDeleted: true }); }; @@ -96,6 +101,7 @@ > {$t('name')} {$t('has_quota')} + {$t('app_version')} {$t('action')} @@ -120,6 +126,17 @@ {/if} + + + {immichUser.appVersion ?? '-'} + + diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 92d1510d40fa6..f2b590781505d 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -32,5 +32,6 @@ export const userAdminFactory = Sync.makeFactory({ activationKey: 'activation-key', activatedAt: new Date().toISOString(), }, + appVersion: '1.136.0', profileChangedAt: Sync.each(() => faker.date.recent().toISOString()), });