Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion mobile/openapi/lib/model/user_admin_response_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -17035,6 +17035,10 @@
},
"UserAdminResponseDto": {
"properties": {
"appVersion": {
"nullable": true,
"type": "string"
},
"avatarColor": {
"allOf": [
{
Expand Down
1 change: 1 addition & 0 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export type UserLicense = {
licenseKey: string;
};
export type UserAdminResponseDto = {
appVersion?: string | null;
avatarColor: UserAvatarColor;
createdAt: string;
deletedAt: string | null;
Expand Down
13 changes: 12 additions & 1 deletion server/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type AuthUser = {
email: string;
quotaUsageInBytes: number;
quotaSizeInBytes: number | null;
appVersion: string | null;
};

export type AlbumUser = {
Expand Down Expand Up @@ -142,6 +143,7 @@ export type UserAdmin = User & {
quotaUsageInBytes: number;
status: UserStatus;
metadata: UserMetadataItem[];
appVersion: string | null;
};

export type StorageAsset = {
Expand Down Expand Up @@ -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: [
Expand All @@ -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'],
Expand Down
3 changes: 3 additions & 0 deletions server/src/dtos/user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion server/src/queries/api.key.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."appVersion"
from
"user"
where
Expand Down
3 changes: 2 additions & 1 deletion server/src/queries/session.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."appVersion"
from
"user"
where
Expand Down
3 changes: 2 additions & 1 deletion server/src/queries/shared.link.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."appVersion"
from
"user"
where
Expand Down
9 changes: 8 additions & 1 deletion server/src/queries/user.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ select
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"appVersion",
(
select
coalesce(json_agg(agg), '[]')
Expand Down Expand Up @@ -58,6 +59,7 @@ select
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"appVersion",
(
select
coalesce(json_agg(agg), '[]')
Expand Down Expand Up @@ -137,6 +139,7 @@ select
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"appVersion",
(
select
coalesce(json_agg(agg), '[]')
Expand Down Expand Up @@ -175,7 +178,8 @@ select
"shouldChangePassword",
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes"
"quotaUsageInBytes",
"appVersion"
from
"user"
where
Expand All @@ -201,6 +205,7 @@ select
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"appVersion",
(
select
coalesce(json_agg(agg), '[]')
Expand Down Expand Up @@ -248,6 +253,7 @@ select
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"appVersion",
(
select
coalesce(json_agg(agg), '[]')
Expand Down Expand Up @@ -286,6 +292,7 @@ select
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"appVersion",
(
select
coalesce(json_agg(agg), '[]')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "user" ADD "appVersion" character varying;`.execute(db);
}

export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "user" DROP COLUMN "appVersion";`.execute(db);
}
3 changes: 3 additions & 0 deletions server/src/schema/tables/user.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,7 @@ export class UserTable {

@UpdateIdColumn({ index: true })
updateId!: Generated<string>;

@Column({ type: 'character varying', nullable: true, default: null })
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need a default if it's null anyways?

Copy link
Copy Markdown
Collaborator Author

@aviv926 aviv926 Jul 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure, so I used the logic as we use in

  @Column({ type: 'character varying', nullable: true, default: null })
  color!: string | null;

If you think it's not necessary (as it seems I will remove it from appVersion Column)

appVersion!: string | null;
}
12 changes: 12 additions & 0 deletions server/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_(?<platform>Android|iOS)_(?<version>.+)$/.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');
Expand Down
3 changes: 3 additions & 0 deletions server/test/fixtures/auth.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const authUser = {
isAdmin: true,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
appVersion: '1.136.0',
},
user1: {
id: 'user-id',
Expand All @@ -17,6 +18,7 @@ const authUser = {
isAdmin: false,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
appVersion: '1.136.0',
},
};

Expand All @@ -36,6 +38,7 @@ export const authStub = {
isAdmin: false,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
appVersion: '1.136.0',
},
session: {
id: 'token-id',
Expand Down
5 changes: 4 additions & 1 deletion server/test/small.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,10 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
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<Partner> = {}) => {
Expand Down Expand Up @@ -179,6 +180,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
quotaUsageInBytes = 0,
status = UserStatus.Active,
metadata = [],
appVersion = '1.0.0',
} = user;
return {
id,
Expand All @@ -198,6 +200,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
quotaUsageInBytes,
status,
metadata,
appVersion,
};
};

Expand Down
19 changes: 18 additions & 1 deletion web/src/routes/admin/users/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +31,11 @@

let allUsers: UserAdminResponseDto[] = $state([]);

const { serverVersion } = websocketStore;
Comment thread
aviv926 marked this conversation as resolved.
let formattedServerVersion = $derived(
$serverVersion ? `${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);

const refresh = async () => {
allUsers = await searchUsersAdmin({ withDeleted: true });
};
Expand Down Expand Up @@ -96,6 +101,7 @@
>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
<th class="w-2/12 text-center text-sm font-medium">{$t('app_version')}</th>
<th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">{$t('action')}</th>
</tr>
</thead>
Expand All @@ -120,6 +126,17 @@
{/if}
</div>
</td>
<td class="w-2/12 text-ellipsis break-all px-2 text-sm">
<span
class={immichUser.appVersion &&
formattedServerVersion &&
immichUser.appVersion !== formattedServerVersion
? 'text-red-500'
: ''}
>
{immichUser.appVersion ?? '-'}
</span>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
Expand Down
1 change: 1 addition & 0 deletions web/src/test-data/factories/user-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
activationKey: 'activation-key',
activatedAt: new Date().toISOString(),
},
appVersion: '1.136.0',
profileChangedAt: Sync.each(() => faker.date.recent().toISOString()),
});
Loading