feat: add mobile device version tracking

This commit is contained in:
aviv 2025-08-27 18:55:00 +03:00
parent 76eaee3657
commit 6a359f0372
16 changed files with 236 additions and 13 deletions

View file

@ -30,8 +30,27 @@ class UserAdminResponseDto {
required this.status,
required this.storageLabel,
required this.updatedAt,
required this.mobileDevices,
});
List<MobileDeviceDto> mobileDevices;
}
class MobileDeviceDto {
MobileDeviceDto({
required this.deviceType,
required this.deviceOS,
required this.appVersion,
required this.lastSeen,
});
String deviceType;
String deviceOS;
String appVersion;
DateTime lastSeen;
}
UserAvatarColor avatarColor;
DateTime createdAt;

View file

@ -12348,6 +12348,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": {
@ -17067,6 +17091,12 @@
],
"nullable": true
},
"mobileDevices": {
"items": {
"$ref": "#/components/schemas/MobileDeviceDto"
},
"type": "array"
},
"name": {
"type": "string"
},
@ -17117,6 +17147,7 @@
"id",
"isAdmin",
"license",
"mobileDevices",
"name",
"oauthId",
"profileChangedAt",

View file

@ -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;
};

View file

@ -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<UserMetadataKey.License> => item.key === UserMetadataKey.License,
)?.value;
const mobileDevices = (entity.sessions || [])
.filter((s) => s.appVersion && s.appVersion !== '')
.map((s) => ({
deviceType: s.deviceType,
deviceOS: s.deviceOS,
appVersion: s.appVersion,
lastSeen: s.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,
};
}

View file

@ -56,13 +56,21 @@ export const FileResponse = () =>
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
const request = context.switchToHttp().getRequest<Request>();
const userAgent = UAParser(request.headers['user-agent']);
const userAgentString = request.headers['user-agent'] as string || '';
const userAgent = UAParser(userAgentString);
let appVersion = '';
const immichMatch = userAgentString.match(/^Immich_(Android|iOS)_(.+)$/);
if (immichMatch) {
appVersion = immichMatch[2];
}
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) || '',
appVersion,
};
});
@ -86,7 +94,6 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
if (!options) {
return true;
@ -105,6 +112,24 @@ export class AuthGuard implements CanActivate {
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
});
if (request.user?.session) {
let appVersion = '';
const userAgent = request.headers['user-agent'] as string || '';
const immichMatch = userAgent.match(/^Immich_(Android|iOS)_(.+)$/);
if (immichMatch) {
appVersion = immichMatch[2];
}
if (request.headers['x-app-version']) {
appVersion = request.headers['x-app-version'] as string;
}
if (appVersion) {
await this.authService.heartbeat(request.user, appVersion);
} else {
// Always update lastSeen even if appVersion is missing
await this.authService.heartbeat(request.user, '');
}
}
return true;
}
}

View file

@ -13,6 +13,20 @@ export type SessionSearchOptions = { updatedBefore: Date };
@Injectable()
export class SessionRepository {
async cleanupMobileStaleSessions() {
const deleted = await this.db
.deleteFrom('session')
.where((eb) =>
eb.and([
eb('updatedAt', '<=', DateTime.now().minus({ minutes: 1 }).toJSDate()),
eb('deviceOS', 'in', ['Android', 'iOS']),
])
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
return deleted;
}
constructor(@InjectKysely() private db: Kysely<DB>) {}
cleanup() {

View file

@ -69,14 +69,22 @@ export class UserRepository {
}
@GenerateSql()
getAdmin() {
return this.db
async getAdmin() {
const user = await this.db
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.where('user.isAdmin', '=', true)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
if (user) {
(user as any).sessions = await this.db
.selectFrom('session')
.selectAll()
.where('session.userId', '=', user.id)
.execute();
}
return user as any;
}
@GenerateSql()
@ -163,8 +171,8 @@ export class UserRepository {
{ name: 'with deleted', params: [{ withDeleted: true }] },
{ name: 'without deleted', params: [{ withDeleted: false }] },
)
getList({ id, withDeleted }: UserListFilter = {}) {
return this.db
async getList({ id, withDeleted }: UserListFilter = {}) {
const users = await this.db
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
@ -172,6 +180,14 @@ export class UserRepository {
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
.orderBy('createdAt', 'desc')
.execute();
for (const user of users) {
(user as any).sessions = await this.db
.selectFrom('session')
.selectAll()
.where('session.userId', '=', user.id)
.execute();
}
return users as any;
}
async create(dto: Insertable<UserTable>) {

View file

@ -0,0 +1,15 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('session')
.addColumn('appVersion', 'varchar(32)', (col) => col.defaultTo('').notNull())
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('session')
.dropColumn('appVersion')
.execute();
}

View file

@ -42,6 +42,9 @@ export class SessionTable {
@Column({ default: '' })
deviceOS!: Generated<string>;
@Column({ default: '' })
appVersion!: Generated<string>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;

View file

@ -34,6 +34,7 @@ export interface LoginDetails {
clientIp: string;
deviceType: string;
deviceOS: string;
appVersion?: string;
}
interface ClaimOptions<T> {
@ -56,6 +57,23 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
/**
* Cleanup mobile sessions not seen for over a week
*/
async cleanupStaleMobileSessions(): Promise<number> {
const deleted = await this.sessionRepository.cleanupMobileStaleSessions();
return deleted.length ?? 0;
}
async heartbeat(auth: AuthDto, appVersion: string): Promise<void> {
if (!auth.session) {
throw new BadRequestException('No active session');
}
await this.sessionRepository.update(auth.session.id, {
appVersion,
updatedAt: new Date(),
});
}
async login(dto: LoginCredentialDto, details: LoginDetails) {
const config = await this.getConfig({ withCache: false });
if (!config.passwordLogin.enabled) {
@ -529,6 +547,7 @@ export class AuthService extends BaseService {
token: tokenHashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
appVersion: loginDetails.appVersion || '',
userId: user.id,
});

View file

@ -8,7 +8,7 @@ import { BaseService } from 'src/services/base.service';
export class CliService extends BaseService {
async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUserAdmin(user));
return users.map((user:any) => mapUserAdmin(user));
}
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {

View file

@ -16,7 +16,7 @@ export class MemoryService extends BaseService {
async onMemoriesCreate() {
const users = await this.userRepository.getList({ withDeleted: false });
const usersIds = await Promise.all(
users.map((user) =>
users.map((user:any) =>
getMyPartnerIds({
userId: user.id,
repository: this.partnerRepository,
@ -38,7 +38,7 @@ export class MemoryService extends BaseService {
}
try {
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
await Promise.all(users.map((owner:any, i:any) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
} catch (error) {
this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
}

View file

@ -167,7 +167,7 @@ export class StorageTemplateService extends BaseService {
const users = await this.userRepository.getList();
for await (const asset of assets) {
const user = users.find((user) => user.id === asset.ownerId);
const user = users.find((user:any) => user.id === asset.ownerId);
const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename });

View file

@ -23,7 +23,7 @@ export class UserAdminService extends BaseService {
id: dto.id,
withDeleted: dto.withDeleted,
});
return users.map((user) => mapUserAdmin(user));
return users.map((user:any) => mapUserAdmin(user));
}
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {

View file

@ -31,10 +31,11 @@ export class UserService extends BaseService {
users = authUser ? [authUser] : [];
}
return users.map((user) => mapUser(user));
return users.map((user:any) => mapUser(user));
}
async getMe(auth: AuthDto): Promise<UserAdminResponseDto> {
await this.sessionRepository.cleanupMobileStaleSessions();
const user = await this.userRepository.get(auth.user.id, {});
if (!user) {
throw new BadRequestException('User not found');

View file

@ -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));
@ -355,6 +363,50 @@
{/if}
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<div class="flex items-center gap-2 px-4 py-2 text-primary">
<Icon icon={mdiAppsBox} size="1.5rem" />
<CardTitle>Mobile Devices</CardTitle>
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
<Stack gap={3}>
{#if user.mobileDevices && user.mobileDevices.length > 0}
{#each user.mobileDevices as device}
<div
class="flex items-center gap-4 p-3 rounded-lg bg-gray-50 dark:bg-immich-dark-primary/10 shadow-sm"
>
<span class="flex items-center justify-center">
{#if device.deviceType === 'Android' || device.deviceOS === 'Android'}
<Icon icon={mdiAndroid} size="2rem" class="text-green-600" />
{: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'}
<Icon icon={mdiApple} size="2rem" class="text-gray-700" />
{:else}
<Icon icon={mdiHelp} size="2rem" class="text-gray-400" />
{/if}
</span>
<div class="flex flex-col flex-1">
<span class="font-medium text-base"
>{device.deviceType} <span class="mx-1"></span>
<span style="color: {device.appVersion < formattedServerVersion ? 'red' : 'inherit'}">
v{device.appVersion}
</span>
</span>
<span class="text-xs text-gray-500"
>Last seen: {new Date(device.lastSeen).toLocaleString()}</span
>
</div>
</div>
{/each}
{:else}
<span class="text-gray-400">No mobile devices</span>
{/if}
</Stack>
</div>
</CardBody>
</Card>
</div>
</Container>
</div>