This commit is contained in:
aviv926 2025-10-16 12:28:33 +05:30 committed by GitHub
commit c2e203e2bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 516 additions and 15 deletions

View file

@ -407,6 +407,7 @@ Class | Method | HTTP request | Description
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
- [MobileDeviceDto](doc//MobileDeviceDto.md)
- [NotificationCreateDto](doc//NotificationCreateDto.md)
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
- [NotificationDto](doc//NotificationDto.md)

View file

@ -178,6 +178,7 @@ part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
part 'model/mobile_device_dto.dart';
part 'model/notification_create_dto.dart';
part 'model/notification_delete_all_dto.dart';
part 'model/notification_dto.dart';

View file

@ -410,6 +410,8 @@ class ApiClient {
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
case 'MobileDeviceDto':
return MobileDeviceDto.fromJson(value);
case 'NotificationCreateDto':
return NotificationCreateDto.fromJson(value);
case 'NotificationDeleteAllDto':

View file

@ -0,0 +1,123 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MobileDeviceDto {
/// Returns a new [MobileDeviceDto] instance.
MobileDeviceDto({
required this.appVersion,
required this.deviceOS,
required this.deviceType,
required this.lastSeen,
});
String appVersion;
String deviceOS;
String deviceType;
DateTime lastSeen;
@override
bool operator ==(Object other) => identical(this, other) || other is MobileDeviceDto &&
other.appVersion == appVersion &&
other.deviceOS == deviceOS &&
other.deviceType == deviceType &&
other.lastSeen == lastSeen;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(appVersion.hashCode) +
(deviceOS.hashCode) +
(deviceType.hashCode) +
(lastSeen.hashCode);
@override
String toString() => 'MobileDeviceDto[appVersion=$appVersion, deviceOS=$deviceOS, deviceType=$deviceType, lastSeen=$lastSeen]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'appVersion'] = this.appVersion;
json[r'deviceOS'] = this.deviceOS;
json[r'deviceType'] = this.deviceType;
json[r'lastSeen'] = this.lastSeen.toUtc().toIso8601String();
return json;
}
/// Returns a new [MobileDeviceDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MobileDeviceDto? fromJson(dynamic value) {
upgradeDto(value, "MobileDeviceDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MobileDeviceDto(
appVersion: mapValueOfType<String>(json, r'appVersion')!,
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
deviceType: mapValueOfType<String>(json, r'deviceType')!,
lastSeen: mapDateTime(json, r'lastSeen', r'')!,
);
}
return null;
}
static List<MobileDeviceDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MobileDeviceDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MobileDeviceDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MobileDeviceDto> mapFromJson(dynamic json) {
final map = <String, MobileDeviceDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MobileDeviceDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MobileDeviceDto-objects as value to a dart map
static Map<String, List<MobileDeviceDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MobileDeviceDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MobileDeviceDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'appVersion',
'deviceOS',
'deviceType',
'lastSeen',
};
}

View file

@ -20,6 +20,7 @@ class UserAdminResponseDto {
required this.id,
required this.isAdmin,
required this.license,
this.mobileDevices = const [],
required this.name,
required this.oauthId,
required this.profileChangedAt,
@ -46,6 +47,8 @@ class UserAdminResponseDto {
UserLicense? license;
List<MobileDeviceDto> mobileDevices;
String name;
String oauthId;
@ -75,6 +78,7 @@ class UserAdminResponseDto {
other.id == id &&
other.isAdmin == isAdmin &&
other.license == license &&
_deepEquality.equals(other.mobileDevices, mobileDevices) &&
other.name == name &&
other.oauthId == oauthId &&
other.profileChangedAt == profileChangedAt &&
@ -96,6 +100,7 @@ class UserAdminResponseDto {
(id.hashCode) +
(isAdmin.hashCode) +
(license == null ? 0 : license!.hashCode) +
(mobileDevices.hashCode) +
(name.hashCode) +
(oauthId.hashCode) +
(profileChangedAt.hashCode) +
@ -108,7 +113,7 @@ 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[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, mobileDevices=$mobileDevices, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -127,6 +132,7 @@ class UserAdminResponseDto {
} else {
// json[r'license'] = null;
}
json[r'mobileDevices'] = this.mobileDevices;
json[r'name'] = this.name;
json[r'oauthId'] = this.oauthId;
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
@ -168,6 +174,7 @@ class UserAdminResponseDto {
id: mapValueOfType<String>(json, r'id')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
license: UserLicense.fromJson(json[r'license']),
mobileDevices: MobileDeviceDto.listFromJson(json[r'mobileDevices']),
name: mapValueOfType<String>(json, r'name')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
@ -232,6 +239,7 @@ class UserAdminResponseDto {
'id',
'isAdmin',
'license',
'mobileDevices',
'name',
'oauthId',
'profileChangedAt',

View file

@ -12724,6 +12724,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": {
@ -17523,6 +17547,12 @@
],
"nullable": true
},
"mobileDevices": {
"items": {
"$ref": "#/components/schemas/MobileDeviceDto"
},
"type": "array"
},
"name": {
"type": "string"
},
@ -17573,6 +17603,7 @@
"id",
"isAdmin",
"license",
"mobileDevices",
"name",
"oauthId",
"profileChangedAt",

View file

@ -87,6 +87,12 @@ export type UserLicense = {
activationKey: string;
licenseKey: string;
};
export type MobileDeviceDto = {
appVersion: string;
deviceOS: string;
deviceType: string;
lastSeen: string;
};
export type UserAdminResponseDto = {
avatarColor: UserAvatarColor;
createdAt: string;
@ -95,6 +101,7 @@ export type UserAdminResponseDto = {
id: string;
isAdmin: boolean;
license: (UserLicense) | null;
mobileDevices: MobileDeviceDto[];
name: string;
oauthId: string;
profileChangedAt: string;

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(({ appVersion }) => appVersion)
.map(({ deviceType, deviceOS, appVersion, updatedAt }) => ({
deviceType,
deviceOS,
appVersion,
lastSeen: 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

@ -54,15 +54,22 @@ export const FileResponse = () =>
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
});
const getAppVersionFromUA = (ua: string) =>
ua.match(/^Immich_(?:Android|iOS)_(?<appVersion>.+)$/)?.groups?.appVersion ?? '';
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
const request = context.switchToHttp().getRequest<Request>();
const userAgent = UAParser(request.headers['user-agent']);
const userAgentString = request.get('user-agent') || '';
const userAgent = UAParser(userAgentString);
const appVersion = getAppVersionFromUA(userAgentString);
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: userAgent.browser.name || userAgent.device.type || request.get('devicemodel') || '',
deviceOS: userAgent.os.name || request.get('devicetype') || '',
appVersion,
};
});
@ -86,7 +93,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 +111,12 @@ export class AuthGuard implements CanActivate {
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
});
if (request.user?.session) {
const userAgent = request.get('user-agent') || '';
const appVersion = request.get('x-app-version') || getAppVersionFromUA(userAgent);
await this.authService.heartbeat(request.user, appVersion);
}
return true;
}
}

View file

@ -32,7 +32,28 @@ select
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
where
@ -71,7 +92,28 @@ select
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
where
@ -150,7 +192,28 @@ select
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
where
@ -175,7 +238,42 @@ select
"shouldChangePassword",
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes"
"quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
where
@ -214,7 +312,28 @@ select
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
where
@ -261,7 +380,28 @@ select
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
order by
@ -299,7 +439,28 @@ select
where
"user"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
) as "metadata",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"session"."id",
"session"."createdAt",
"session"."updatedAt",
"session"."expiresAt",
"session"."deviceOS",
"session"."deviceType",
"session"."appVersion",
"session"."pinExpiresAt",
"session"."isPendingSyncReset"
from
"session"
where
"session"."userId" = "user"."id"
) as agg
) as "sessions"
from
"user"
where

View file

@ -40,6 +40,25 @@ const withMetadata = (eb: ExpressionBuilder<DB, 'user'>) => {
).as('metadata');
};
const withSessions = (eb: ExpressionBuilder<DB, 'user'>) => {
return jsonArrayFrom(
eb
.selectFrom('session')
.select([
'session.id',
'session.createdAt',
'session.updatedAt',
'session.expiresAt',
'session.deviceOS',
'session.deviceType',
'session.appVersion',
'session.pinExpiresAt',
'session.isPendingSyncReset',
])
.whereRef('session.userId', '=', 'user.id'),
).as('sessions');
};
@Injectable()
export class UserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@ -52,6 +71,7 @@ export class UserRepository {
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.select(withSessions)
.where('user.id', '=', userId)
.$if(!options.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
.executeTakeFirst();
@ -66,11 +86,12 @@ export class UserRepository {
}
@GenerateSql()
getAdmin() {
async getAdmin() {
return this.db
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.select(withSessions)
.where('user.isAdmin', '=', true)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
@ -124,6 +145,7 @@ export class UserRepository {
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.select(withSessions)
.$if(!!options?.withPassword, (eb) => eb.select('password'))
.where('email', '=', email)
.where('user.deletedAt', 'is', null)
@ -135,6 +157,8 @@ export class UserRepository {
return this.db
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.select(withSessions)
.where('user.storageLabel', '=', storageLabel)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
@ -146,6 +170,7 @@ export class UserRepository {
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.select(withSessions)
.where('user.oauthId', '=', oauthId)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
@ -160,11 +185,12 @@ export class UserRepository {
{ name: 'with deleted', params: [{ withDeleted: true }] },
{ name: 'without deleted', params: [{ withDeleted: false }] },
)
getList({ id, withDeleted }: UserListFilter = {}) {
async getList({ id, withDeleted }: UserListFilter = {}) {
return this.db
.selectFrom('user')
.select(columns.userAdmin)
.select(withMetadata)
.select(withSessions)
.$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
.orderBy('createdAt', 'desc')
@ -177,6 +203,7 @@ export class UserRepository {
.values(dto)
.returning(columns.userAdmin)
.returning(withMetadata)
.returning(withSessions)
.executeTakeFirstOrThrow();
}
@ -188,6 +215,7 @@ export class UserRepository {
.where('user.deletedAt', 'is', null)
.returning(columns.userAdmin)
.returning(withMetadata)
.returning(withSessions)
.executeTakeFirstOrThrow();
}
@ -202,6 +230,7 @@ export class UserRepository {
.where('user.id', '=', asUuid(id))
.returning(columns.userAdmin)
.returning(withMetadata)
.returning(withSessions)
.executeTakeFirstOrThrow();
}

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" ADD "appVersion" character varying NOT NULL DEFAULT '';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db);
}

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,18 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
async heartbeat(auth: AuthDto, appVersion: string): Promise<void> {
if (!auth.session) {
throw new BadRequestException('No active session');
}
const updateData: { appVersion?: string; updatedAt: Date } = {
updatedAt: new Date(),
};
if (appVersion) {
updateData.appVersion = appVersion;
}
await this.sessionRepository.update(auth.session.id, updateData);
}
async login(dto: LoginCredentialDto, details: LoginDetails) {
const config = await this.getConfig({ withCache: false });
if (!config.passwordLogin.enabled) {
@ -529,6 +542,7 @@ export class AuthService extends BaseService {
token: tokenHashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
appVersion: loginDetails.appVersion || '',
userId: user.id,
});

View file

@ -20,6 +20,7 @@ export const userStub = {
metadata: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
sessions: [],
},
user1: <UserAdmin>{
...authStub.user1.user,
@ -37,6 +38,7 @@ export const userStub = {
metadata: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
sessions: [],
},
user2: <UserAdmin>{
...authStub.user2.user,
@ -54,5 +56,6 @@ export const userStub = {
updatedAt: new Date('2021-01-01'),
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
sessions: [],
},
};

3
server/test/small.factory.ts Normal file → Executable file
View file

@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
userId: newUuid(),
pinExpiresAt: newDate(),
isPendingSyncReset: false,
appVersion: session.appVersion ?? '',
...session,
});
@ -179,6 +180,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
quotaUsageInBytes = 0,
status = UserStatus.Active,
metadata = [],
sessions = [],
} = user;
return {
id,
@ -198,6 +200,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
quotaUsageInBytes,
status,
metadata,
sessions,
};
};

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));
@ -350,6 +358,56 @@
{/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 (`${device.deviceType}-${device.deviceOS}-${device.lastSeen}`)}
<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>
{#if formattedServerVersion != null && device.appVersion < formattedServerVersion}
<span style="color: red">v{device.appVersion}</span>
{:else if formattedServerVersion != null && device.appVersion > formattedServerVersion}
<span style="color: green">v{device.appVersion}</span>
<br />
<span class="text-green-600 text-xs">(App version is newer than server)</span>
{:else}
<span style="color: inherit">v{device.appVersion}</span>
{/if}
</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>

View file

@ -17,6 +17,14 @@ export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
name: Sync.each(() => faker.person.fullName()),
profileImagePath: '',
avatarColor: UserAvatarColor.Primary,
mobileDevices: [
{
deviceType: 'SM-S938B',
deviceOS: 'Android',
appVersion: '1.139.4',
lastSeen: '2025-08-28T01:57:37.170Z',
},
],
isAdmin: true,
createdAt: Sync.each(() => faker.date.recent().toISOString()),
updatedAt: Sync.each(() => faker.date.recent().toISOString()),