mirror of
https://github.com/immich-app/immich.git
synced 2025-11-01 02:57:08 -04:00
feat: view the user's app version on the user page (#21345)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
parent
c3a533ab40
commit
032de9ff2f
@ -119,5 +119,6 @@ export const deviceDto = {
|
||||
isPendingSyncReset: false,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
appVersion: null,
|
||||
},
|
||||
};
|
||||
|
||||
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@ -282,6 +282,7 @@ Class | Method | HTTP request | Description
|
||||
*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} |
|
||||
*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} |
|
||||
*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences |
|
||||
*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions |
|
||||
*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics |
|
||||
*UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore |
|
||||
*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |
|
||||
|
||||
56
mobile/openapi/lib/api/users_admin_api.dart
generated
56
mobile/openapi/lib/api/users_admin_api.dart
generated
@ -231,6 +231,62 @@ class UsersAdminApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint is an admin-only route, and requires the `adminSession.read` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> getUserSessionsAdminWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/users/{id}/sessions'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint is an admin-only route, and requires the `adminSession.read` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<List<SessionResponseDto>?> getUserSessionsAdmin(String id,) async {
|
||||
final response = await getUserSessionsAdminWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<SessionResponseDto>') as List)
|
||||
.cast<SessionResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint is an admin-only route, and requires the `adminUser.read` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@ -150,6 +150,7 @@ class Permission {
|
||||
static const adminUserPeriodRead = Permission._(r'adminUser.read');
|
||||
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
|
||||
static const adminUserPeriodDelete = Permission._(r'adminUser.delete');
|
||||
static const adminSessionPeriodRead = Permission._(r'adminSession.read');
|
||||
static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll');
|
||||
|
||||
/// List of all possible values in this [enum][Permission].
|
||||
@ -281,6 +282,7 @@ class Permission {
|
||||
adminUserPeriodRead,
|
||||
adminUserPeriodUpdate,
|
||||
adminUserPeriodDelete,
|
||||
adminSessionPeriodRead,
|
||||
adminAuthPeriodUnlinkAll,
|
||||
];
|
||||
|
||||
@ -447,6 +449,7 @@ class PermissionTypeTransformer {
|
||||
case r'adminUser.read': return Permission.adminUserPeriodRead;
|
||||
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
|
||||
case r'adminUser.delete': return Permission.adminUserPeriodDelete;
|
||||
case r'adminSession.read': return Permission.adminSessionPeriodRead;
|
||||
case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
||||
@ -13,6 +13,7 @@ part of openapi.api;
|
||||
class SessionCreateResponseDto {
|
||||
/// Returns a new [SessionCreateResponseDto] instance.
|
||||
SessionCreateResponseDto({
|
||||
required this.appVersion,
|
||||
required this.createdAt,
|
||||
required this.current,
|
||||
required this.deviceOS,
|
||||
@ -24,6 +25,8 @@ class SessionCreateResponseDto {
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String? appVersion;
|
||||
|
||||
String createdAt;
|
||||
|
||||
bool current;
|
||||
@ -50,6 +53,7 @@ class SessionCreateResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SessionCreateResponseDto &&
|
||||
other.appVersion == appVersion &&
|
||||
other.createdAt == createdAt &&
|
||||
other.current == current &&
|
||||
other.deviceOS == deviceOS &&
|
||||
@ -63,6 +67,7 @@ class SessionCreateResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(appVersion == null ? 0 : appVersion!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(current.hashCode) +
|
||||
(deviceOS.hashCode) +
|
||||
@ -74,10 +79,15 @@ class SessionCreateResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]';
|
||||
String toString() => 'SessionCreateResponseDto[appVersion=$appVersion, createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.appVersion != null) {
|
||||
json[r'appVersion'] = this.appVersion;
|
||||
} else {
|
||||
// json[r'appVersion'] = null;
|
||||
}
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'current'] = this.current;
|
||||
json[r'deviceOS'] = this.deviceOS;
|
||||
@ -103,6 +113,7 @@ class SessionCreateResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SessionCreateResponseDto(
|
||||
appVersion: mapValueOfType<String>(json, r'appVersion'),
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
current: mapValueOfType<bool>(json, r'current')!,
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||
@ -159,6 +170,7 @@ class SessionCreateResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'appVersion',
|
||||
'createdAt',
|
||||
'current',
|
||||
'deviceOS',
|
||||
|
||||
14
mobile/openapi/lib/model/session_response_dto.dart
generated
14
mobile/openapi/lib/model/session_response_dto.dart
generated
@ -13,6 +13,7 @@ part of openapi.api;
|
||||
class SessionResponseDto {
|
||||
/// Returns a new [SessionResponseDto] instance.
|
||||
SessionResponseDto({
|
||||
required this.appVersion,
|
||||
required this.createdAt,
|
||||
required this.current,
|
||||
required this.deviceOS,
|
||||
@ -23,6 +24,8 @@ class SessionResponseDto {
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String? appVersion;
|
||||
|
||||
String createdAt;
|
||||
|
||||
bool current;
|
||||
@ -47,6 +50,7 @@ class SessionResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SessionResponseDto &&
|
||||
other.appVersion == appVersion &&
|
||||
other.createdAt == createdAt &&
|
||||
other.current == current &&
|
||||
other.deviceOS == deviceOS &&
|
||||
@ -59,6 +63,7 @@ class SessionResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(appVersion == null ? 0 : appVersion!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(current.hashCode) +
|
||||
(deviceOS.hashCode) +
|
||||
@ -69,10 +74,15 @@ class SessionResponseDto {
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]';
|
||||
String toString() => 'SessionResponseDto[appVersion=$appVersion, createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.appVersion != null) {
|
||||
json[r'appVersion'] = this.appVersion;
|
||||
} else {
|
||||
// json[r'appVersion'] = null;
|
||||
}
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'current'] = this.current;
|
||||
json[r'deviceOS'] = this.deviceOS;
|
||||
@ -97,6 +107,7 @@ class SessionResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SessionResponseDto(
|
||||
appVersion: mapValueOfType<String>(json, r'appVersion'),
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
current: mapValueOfType<bool>(json, r'current')!,
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||
@ -152,6 +163,7 @@ class SessionResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'appVersion',
|
||||
'createdAt',
|
||||
'current',
|
||||
'deviceOS',
|
||||
|
||||
@ -773,6 +773,54 @@
|
||||
"description": "This endpoint is an admin-only route, and requires the `adminUser.delete` permission."
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/sessions": {
|
||||
"get": {
|
||||
"operationId": "getUserSessionsAdmin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SessionResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Users (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-permission": "adminSession.read",
|
||||
"description": "This endpoint is an admin-only route, and requires the `adminSession.read` permission."
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/statistics": {
|
||||
"get": {
|
||||
"operationId": "getUserStatisticsAdmin",
|
||||
@ -13267,6 +13315,7 @@
|
||||
"adminUser.read",
|
||||
"adminUser.update",
|
||||
"adminUser.delete",
|
||||
"adminSession.read",
|
||||
"adminAuth.unlinkAll"
|
||||
],
|
||||
"type": "string"
|
||||
@ -14303,6 +14352,10 @@
|
||||
},
|
||||
"SessionCreateResponseDto": {
|
||||
"properties": {
|
||||
"appVersion": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -14332,6 +14385,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appVersion",
|
||||
"createdAt",
|
||||
"current",
|
||||
"deviceOS",
|
||||
@ -14345,6 +14399,10 @@
|
||||
},
|
||||
"SessionResponseDto": {
|
||||
"properties": {
|
||||
"appVersion": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -14371,6 +14429,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appVersion",
|
||||
"createdAt",
|
||||
"current",
|
||||
"deviceOS",
|
||||
|
||||
@ -244,6 +244,17 @@ export type UserPreferencesUpdateDto = {
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
tags?: TagsUpdate;
|
||||
};
|
||||
export type SessionResponseDto = {
|
||||
appVersion: string | null;
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
expiresAt?: string;
|
||||
id: string;
|
||||
isPendingSyncReset: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type AssetStatsResponseDto = {
|
||||
images: number;
|
||||
total: number;
|
||||
@ -1192,16 +1203,6 @@ export type ServerVersionHistoryResponseDto = {
|
||||
id: string;
|
||||
version: string;
|
||||
};
|
||||
export type SessionResponseDto = {
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
expiresAt?: string;
|
||||
id: string;
|
||||
isPendingSyncReset: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SessionCreateDto = {
|
||||
deviceOS?: string;
|
||||
deviceType?: string;
|
||||
@ -1209,6 +1210,7 @@ export type SessionCreateDto = {
|
||||
duration?: number;
|
||||
};
|
||||
export type SessionCreateResponseDto = {
|
||||
appVersion: string | null;
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
deviceOS: string;
|
||||
@ -1853,6 +1855,19 @@ export function restoreUserAdmin({ id }: {
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint is an admin-only route, and requires the `adminSession.read` permission.
|
||||
*/
|
||||
export function getUserSessionsAdmin({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: SessionResponseDto[];
|
||||
}>(`/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 {
|
||||
|
||||
@ -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<SessionResponseDto[]> {
|
||||
return this.service.getSessions(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/statistics')
|
||||
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
|
||||
getUserStatisticsAdmin(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -173,6 +173,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
||||
const license = metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
||||
)?.value;
|
||||
|
||||
return {
|
||||
...mapUser(entity),
|
||||
storageLabel: entity.storageLabel,
|
||||
|
||||
@ -236,6 +236,8 @@ export enum Permission {
|
||||
AdminUserUpdate = 'adminUser.update',
|
||||
AdminUserDelete = 'adminUser.delete',
|
||||
|
||||
AdminSessionRead = 'adminSession.read',
|
||||
|
||||
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
|
||||
}
|
||||
|
||||
|
||||
@ -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<Request>();
|
||||
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<boolean> {
|
||||
const targets = [context.getHandler()];
|
||||
|
||||
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
||||
if (!options) {
|
||||
return true;
|
||||
|
||||
@ -23,6 +23,7 @@ select
|
||||
"session"."id",
|
||||
"session"."updatedAt",
|
||||
"session"."pinExpiresAt",
|
||||
"session"."appVersion",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
|
||||
@ -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;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db);
|
||||
}
|
||||
@ -42,6 +42,9 @@ export class SessionTable {
|
||||
@Column({ default: '' })
|
||||
deviceOS!: Generated<string>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
appVersion!: string | null;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<T> {
|
||||
@ -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<AuthDto> {
|
||||
private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise<AuthDto> {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@ -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<SessionResponseDto[]> {
|
||||
const sessions = await this.sessionRepository.getByUserId(id);
|
||||
return sessions.map((session) => mapSession(session));
|
||||
}
|
||||
|
||||
async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
|
||||
const stats = await this.assetRepository.getStatistics(id, dto);
|
||||
return mapStats(stats);
|
||||
|
||||
@ -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 = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);
|
||||
|
||||
const getAppVersionFromUA = (ua: string) =>
|
||||
ua.match(/^Immich_(?:Android|iOS)_(?<appVersion>.+)$/)?.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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
||||
userId: newUuid(),
|
||||
pinExpiresAt: newDate(),
|
||||
isPendingSyncReset: false,
|
||||
appVersion: session.appVersion ?? null,
|
||||
...session,
|
||||
});
|
||||
|
||||
|
||||
@ -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 @@
|
||||
|
||||
<div class="flex w-full flex-row">
|
||||
<div class="hidden items-center justify-center pe-2 text-primary sm:flex">
|
||||
{#if device.deviceOS === 'Android'}
|
||||
{#if session.deviceOS === 'Android'}
|
||||
<Icon icon={mdiAndroid} size="40" />
|
||||
{:else if device.deviceOS === 'iOS' || device.deviceOS === 'macOS'}
|
||||
{:else if session.deviceOS === 'iOS' || session.deviceOS === 'macOS'}
|
||||
<Icon icon={mdiApple} size="40" />
|
||||
{:else if device.deviceOS.includes('Safari')}
|
||||
{:else if session.deviceOS.includes('Safari')}
|
||||
<Icon icon={mdiAppleSafari} size="40" />
|
||||
{:else if device.deviceOS.includes('Windows')}
|
||||
{:else if session.deviceOS.includes('Windows')}
|
||||
<Icon icon={mdiMicrosoftWindows} size="40" />
|
||||
{:else if device.deviceOS === 'Linux'}
|
||||
{:else if session.deviceOS === 'Linux'}
|
||||
<Icon icon={mdiLinux} size="40" />
|
||||
{:else if device.deviceOS === 'Ubuntu'}
|
||||
{:else if session.deviceOS === 'Ubuntu'}
|
||||
<Icon icon={mdiUbuntu} size="40" />
|
||||
{:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'}
|
||||
{:else if session.deviceOS === 'Chrome OS' || session.deviceType === 'Chrome' || session.deviceType === 'Chromium' || session.deviceType === 'Mobile Chrome'}
|
||||
<Icon icon={mdiGoogleChrome} size="40" />
|
||||
{:else if device.deviceOS === 'Google Cast'}
|
||||
{:else if session.deviceOS === 'Google Cast'}
|
||||
<Icon icon={mdiCast} size="40" />
|
||||
{:else}
|
||||
<Icon icon={mdiHelp} size="40" />
|
||||
@ -55,24 +55,28 @@
|
||||
<div class="flex grow flex-row justify-between gap-1 ps-4 sm:ps-0">
|
||||
<div class="flex flex-col justify-center gap-1 dark:text-white">
|
||||
<span class="text-sm">
|
||||
{#if device.deviceType || device.deviceOS}
|
||||
<span>{device.deviceOS || $t('unknown')} • {device.deviceType || $t('unknown')}</span>
|
||||
{#if session.deviceType || session.deviceOS}
|
||||
<span
|
||||
>{session.deviceOS || $t('unknown')} • {session.deviceType || $t('unknown')}{session.appVersion
|
||||
? `(v${session.appVersion})`
|
||||
: ''}</span
|
||||
>
|
||||
{:else}
|
||||
<span>{$t('unknown')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="text-sm">
|
||||
<span class="">{$t('last_seen')}</span>
|
||||
<span>{DateTime.fromISO(device.updatedAt, { locale: $locale }).toRelativeCalendar(options)}</span>
|
||||
<span>{DateTime.fromISO(session.updatedAt, { locale: $locale }).toRelativeCalendar(options)}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> - </span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{DateTime.fromISO(device.updatedAt, { locale: $locale }).toLocaleString(DateTime.DATETIME_MED, {
|
||||
{DateTime.fromISO(session.updatedAt, { locale: $locale }).toLocaleString(DateTime.DATETIME_MED, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if !device.current && onDelete}
|
||||
{#if !session.current && onDelete}
|
||||
<div>
|
||||
<IconButton
|
||||
color="danger"
|
||||
|
||||
@ -14,8 +14,8 @@
|
||||
|
||||
const refresh = () => getSessions().then((_devices) => (devices = _devices));
|
||||
|
||||
let currentDevice = $derived(devices.find((device) => device.current));
|
||||
let otherDevices = $derived(devices.filter((device) => !device.current));
|
||||
let currentSession = $derived(devices.find((device) => device.current));
|
||||
let otherSessions = $derived(devices.filter((device) => !device.current));
|
||||
|
||||
const handleDelete = async (device: SessionResponseDto) => {
|
||||
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_this_device_confirmation') });
|
||||
@ -54,22 +54,22 @@
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
{#if currentDevice}
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('current_device')}
|
||||
</h3>
|
||||
<DeviceCard device={currentDevice} />
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherDevices.length > 0}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
|
||||
{$t('other_devices')}
|
||||
</h3>
|
||||
{#each otherDevices as device, index (device.id)}
|
||||
<DeviceCard {device} onDelete={() => handleDelete(device)} />
|
||||
{#if index !== otherDevices.length - 1}
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
@ -36,6 +37,7 @@
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiAppsBox,
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
@ -60,11 +62,10 @@
|
||||
let user = $derived(data.user);
|
||||
const userPreferences = $derived(data.userPreferences);
|
||||
const userStatistics = $derived(data.userStatistics);
|
||||
|
||||
const userSessions = $derived(data.userSessions);
|
||||
const TiB = 1024 ** 4;
|
||||
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
||||
|
||||
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 +351,25 @@
|
||||
{/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>Sessions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
<Stack gap={3}>
|
||||
{#each userSessions as session (session.id)}
|
||||
<DeviceCard {session} />
|
||||
{:else}
|
||||
<span class="text-subtle">No mobile devices</span>
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
@ -13,9 +13,10 @@ export const load = (async ({ params, url }) => {
|
||||
redirect(302, AppRoute.ADMIN_USERS);
|
||||
}
|
||||
|
||||
const [userPreferences, userStatistics] = await Promise.all([
|
||||
const [userPreferences, userStatistics, userSessions] = await Promise.all([
|
||||
getUserPreferencesAdmin({ id: user.id }),
|
||||
getUserStatisticsAdmin({ id: user.id }),
|
||||
getUserSessionsAdmin({ id: user.id }),
|
||||
]);
|
||||
|
||||
const $t = await getFormatter();
|
||||
@ -24,6 +25,7 @@ export const load = (async ({ params, url }) => {
|
||||
user,
|
||||
userPreferences,
|
||||
userStatistics,
|
||||
userSessions,
|
||||
meta: {
|
||||
title: $t('admin.user_details'),
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user