Compare commits

..

22 Commits

Author SHA1 Message Date
Alex
1233277f46 merge main 2025-10-22 13:19:48 -05:00
Jason Rasmussen
e196cac6f4 refactor: asset description modal (#23168) 2025-10-22 13:08:59 -05:00
Jason Rasmussen
351c0d2a4d refactor: user delete confirm modal (#23166) 2025-10-22 13:49:06 -04:00
Alex
f4969694cd fix: isolate freeze app on older ios device (#22509)
* fix: isolate freeze app on older ios device

* always use at-least 5 isolates

* fix: bad merge

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-22 12:43:03 -05:00
Daniel Dietzler
b334288529 fix: session list text color (#23165) 2025-10-22 17:33:54 +00:00
Jason Rasmussen
834e52fda6 refactor: user delete (#23163) 2025-10-22 12:54:29 -04:00
Jason Rasmussen
8c27ba3e52 refactor: job events (#23161) 2025-10-22 12:16:55 -04:00
bwees
3df0a9dbf1 chore: code review changes 2025-10-22 10:53:35 -05:00
aviv926
cd8d66f5dd fix(web): show upload speed (#23138)
* remove unnecessary else

* Better fix

* fix: update text color

* chore: stylings

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-10-22 15:40:10 +00:00
Nykri
446f738c7d chore: set default concurrency number to #CPU cores - 1 (#22888)
Set default concurrency number to #CPU cores - 1

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2025-10-22 10:16:07 -05:00
shenlong
f19ad9726f chore(dep): minor mobile dependency updates (#23126)
* chore(dep): minor dependency updates

* build_runner changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-10-22 10:14:44 -05:00
Brandon Wees
65cac118ca fix: allow editing all images (#23144)
* fix: allow editing local asset

* chore: remove isOwner check
2025-10-22 10:12:32 -05:00
Brandon Wees
efac8c6667 fix: semver parser grab everything before hyphen (#23140)
used for versions like 2.1.0-DEBUG
2025-10-22 10:06:40 -05:00
Jason Rasmussen
a70843e2b4 refactor: users.total metric (#23158)
* refactor: users.total metric

* fix: broken test
2025-10-22 10:18:17 -04:00
bo0tzz
0b941d78c4 fix: set TG_NON_INTERACTIVE (#23153) 2025-10-22 13:22:45 +01:00
bo0tzz
fc5fc58759 fix: bump tofu (#23152) 2025-10-22 11:13:03 +00:00
bo0tzz
9bb2fc238a fix: don't use app for final close-duplicates request (#23143) 2025-10-22 11:00:31 +00:00
Alex
76f5036026 chore: improve onboarding, app download links styling (#23134) 2025-10-21 21:10:12 -05:00
bwees
f936b5e292 feat: add conditional check on permission status 2025-10-21 18:51:54 -05:00
bwees
112130c739 fix: translation 2025-10-21 18:48:16 -05:00
bwees
6e1d49fd60 feat: add warning 2025-10-21 18:05:18 -05:00
aviv926
032de9ff2f feat: view the user's app version on the user page (#21345)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-10-22 00:36:18 +02:00
93 changed files with 1074 additions and 1043 deletions

View File

@@ -54,16 +54,10 @@ jobs:
issues: write
discussions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Close issue
if: ${{ github.event_name == 'issues' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.issue.node_id }}
run: |
gh api graphql \
@@ -89,7 +83,7 @@ jobs:
- name: Close discussion
if: ${{ github.event_name == 'discussion' && github.event.discussion.category.name == 'Feature Request' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.discussion.node_id }}
run: |
gh api graphql \

View File

@@ -5,6 +5,9 @@ on:
types:
- completed
env:
TG_NON_INTERACTIVE: 'true'
jobs:
checks:
name: Docs Deploy Checks
@@ -182,7 +185,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run tf output -json'
run: 'mise run tf output -- -json'
- name: Output Cleaning
id: clean

View File

@@ -5,6 +5,9 @@ on:
permissions: {}
env:
TG_NON_INTERACTIVE: 'true'
jobs:
deploy:
name: Docs Destroy
@@ -36,7 +39,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
working-directory: 'deployment/modules/cloudflare/docs'
run: 'mise run tf destroy -refresh=false'
run: 'mise run tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0

View File

@@ -8,6 +8,7 @@ import { serverInfo } from 'src/commands/server-info';
import { version } from '../package.json';
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
const defaultConcurrency = Math.max(1, os.cpus().length - 1);
const program = new Command()
.name('immich')
@@ -66,7 +67,7 @@ program
.addOption(
new Option('-c, --concurrency <number>', 'Number of assets to upload at the same time')
.env('IMMICH_UPLOAD_CONCURRENCY')
.default(4),
.default(defaultConcurrency),
)
.addOption(
new Option('-j, --json-output', 'Output detailed information in json format')

View File

@@ -582,7 +582,7 @@ describe('/tags', () => {
expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
});
it('should remove duplicate assets only once', async () => {
it.skip('should remove duplicate assets only once', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },

View File

@@ -1,4 +1,5 @@
import {
JobName,
LoginResponseDto,
createStack,
deleteUserAdmin,
@@ -327,6 +328,8 @@ describe('/admin/users', () => {
{ headers: asBearerAuth(user.accessToken) },
);
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
const { status, body } = await request(app)
.delete(`/admin/users/${user.userId}`)
.send({ force: true })

View File

@@ -119,5 +119,6 @@ export const deviceDto = {
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
appVersion: null,
},
};

View File

@@ -474,6 +474,7 @@
"app_bar_signout_dialog_title": "Sign out",
"app_download_links": "App Download Links",
"app_settings": "App Settings",
"app_stores": "App Stores",
"app_update_available": "App update is available",
"appears_in": "Appears in",
"apply_count": "Apply ({count, number})",
@@ -745,6 +746,7 @@
"create": "Create",
"create_album": "Create album",
"create_album_page_untitled": "Untitled",
"create_api_key": "Create API key",
"create_library": "Create Library",
"create_link": "Create link",
"create_link_to_share": "Create link to share",
@@ -902,6 +904,7 @@
"enable": "Enable",
"enable_backup": "Enable Backup",
"enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication",
"enable_notifications": "Enable notifications",
"enabled": "Enabled",
"end_date": "End date",
"enqueued": "Enqueued",
@@ -1351,7 +1354,7 @@
"minutes": "Minutes",
"missing": "Missing",
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "You can access these options again from the Utilities page.",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"month": "Month",
"monthly_title_text_date_format": "MMMM y",
@@ -1424,6 +1427,7 @@
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -1433,7 +1437,7 @@
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Please create an API key and select a variant to create your Obtainium configuration link.",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",

View File

@@ -2,8 +2,8 @@
node = "22.20.0"
flutter = "3.35.6"
pnpm = "10.18.1"
terragrunt = "0.58.12"
opentofu = "1.7.1"
terragrunt = "0.91.2"
opentofu = "1.10.6"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

View File

@@ -1,4 +1,6 @@
PODS:
- app_settings (5.1.1):
- Flutter
- background_downloader (0.0.1):
- Flutter
- bonsoir_darwin (0.0.1):
@@ -84,7 +86,7 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (2.0.0):
- photo_manager (3.7.1):
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
@@ -133,6 +135,7 @@ PODS:
- Flutter
DEPENDENCIES:
- app_settings (from `.symlinks/plugins/app_settings/ios`)
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
@@ -178,6 +181,8 @@ SPEC REPOS:
- SwiftyGif
EXTERNAL SOURCES:
app_settings:
:path: ".symlinks/plugins/app_settings/ios"
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
bonsoir_darwin:
@@ -246,6 +251,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
app_settings: 5127ae0678de1dcc19f2293271c51d37c89428b2
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
@@ -262,7 +268,7 @@ SPEC CHECKSUMS:
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
@@ -271,9 +277,9 @@ SPEC CHECKSUMS:
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
@@ -285,7 +291,7 @@ SPEC CHECKSUMS:
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation

View File

@@ -30,9 +30,9 @@ import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
class BackgroundWorkerFgService {
final BackgroundWorkerFgHostApi _foregroundHostApi;
@@ -94,7 +94,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
await Future.wait(
[
loadTranslations(),
workerManager.init(dynamicSpawning: true),
workerManagerPatch.init(dynamicSpawning: true),
_ref?.read(authServiceProvider).setOpenApiServiceEndpoint(),
// Initialize the file downloader
FileDownloader().configure(
@@ -193,7 +193,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_logger.info("Cleaning up background worker");
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManager.dispose().catchError((_) async {
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
@@ -40,10 +41,10 @@ import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
import 'package:worker_manager/worker_manager.dart';
void main() async {
ImmichWidgetsBinding();
@@ -52,7 +53,7 @@ void main() async {
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:app_settings/app_settings.dart';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -8,18 +9,22 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/intl_keys.g.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
@@ -112,6 +117,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
icon: const Icon(Icons.arrow_back_ios_rounded),
),
actions: [
IconButton(
icon: const Icon(Icons.cloud_upload),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
tooltip: "view_details".t(context: context),
),
IconButton(
onPressed: () {
context.pushRoute(const DriftBackupOptionsRoute());
@@ -161,10 +171,40 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
),
),
},
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
FutureBuilder(
future: Permission.notification.isGranted,
builder: (context, snapshot) {
final isBackupEnabled = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.enableBackup);
final isGranted = snapshot.data ?? false;
if (isBackupEnabled && !isGranted && CurrentPlatform.isAndroid) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Column(
spacing: 0,
children: [
Text(
"notification_backup_reliability".t(),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
textAlign: TextAlign.center,
),
TextButton.icon(
onPressed: () => AppSettings.openAppSettings(type: AppSettingsType.notification),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text("enable_notifications".t()),
),
],
),
);
} else {
return const SizedBox.shrink();
}
},
),
],
],

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers

View File

@@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image && isOwner) const EditImageActionButton(),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (isOwner) ...[
if (asset.hasRemote && isOwner && isArchived)
const UnArchiveActionButton(source: ActionSource.viewer)

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart';
@@ -31,7 +32,7 @@ Cancelable<T?> runInIsolateGentle<T>({
throw const InvalidIsolateUsageException();
}
return workerManager.executeGentle((cancelledChecker) async {
return workerManagerPatch.executeGentle((cancelledChecker) async {
T? result;
await runZonedGuarded(
() async {

View File

@@ -15,7 +15,7 @@ class SemVer {
}
factory SemVer.fromString(String version) {
final parts = version.split('.');
final parts = version.split("-")[0].split('.');
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
}

251
mobile/lib/wm_executor.dart Normal file
View File

@@ -0,0 +1,251 @@
// part of 'package:worker_manager/worker_manager.dart';
// ignore_for_file: implementation_imports, avoid_print
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:worker_manager/src/number_of_processors/processors_io.dart';
import 'package:worker_manager/src/worker/worker.dart';
import 'package:worker_manager/worker_manager.dart';
final workerManagerPatch = _Executor();
// [-2^54; 2^53] is compatible with dart2js, see core.int doc
const _minId = -9007199254740992;
const _maxId = 9007199254740992;
class Mixinable<T> {
late final itSelf = this as T;
}
mixin _ExecutorLogger on Mixinable<_Executor> {
var log = false;
@mustCallSuper
void init() {
logMessage("${itSelf._isolatesCount} workers have been spawned and initialized");
}
void logTaskAdded<R>(String uid) {
logMessage("added task with number $uid");
}
@mustCallSuper
void dispose() {
logMessage("worker_manager have been disposed");
}
@mustCallSuper
void _cancel(Task task) {
logMessage("Task ${task.id} have been canceled");
}
void logMessage(String message) {
if (log) print(message);
}
}
class _Executor extends Mixinable<_Executor> with _ExecutorLogger {
final _queue = PriorityQueue<Task>();
final _pool = <Worker>[];
var _nextTaskId = _minId;
var _dynamicSpawning = false;
var _isolatesCount = numberOfProcessors;
@override
Future<void> init({int? isolatesCount, bool? dynamicSpawning}) async {
if (_pool.isNotEmpty) {
print("worker_manager already warmed up, init is ignored. Dispose before init");
return;
}
if (isolatesCount != null) {
if (isolatesCount < 0) {
throw Exception("isolatesCount must be greater than 0");
}
_isolatesCount = isolatesCount;
}
_dynamicSpawning = dynamicSpawning ?? false;
await _ensureWorkersInitialized();
super.init();
}
@override
Future<void> dispose() async {
_queue.clear();
for (final worker in _pool) {
worker.kill();
}
_pool.clear();
super.dispose();
}
Cancelable<R> execute<R>(Execute<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeNow<R>(ExecuteGentle<R> execution) {
final task = TaskGentle<R>(
id: "",
workPriority: WorkPriority.immediately,
execution: execution,
completer: Completer<R>(),
);
Future<void> run() async {
try {
final result = await execution(() => task.canceled);
task.complete(result, null, null);
} catch (error, st) {
task.complete(null, error, st);
}
}
run();
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Cancelable<R> executeWithPort<R, T>(
ExecuteWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
Cancelable<R> executeGentle<R>(ExecuteGentle<R> execution, {WorkPriority priority = WorkPriority.immediately}) {
return _createCancelable<R>(execution: execution, priority: priority);
}
Cancelable<R> executeGentleWithPort<R, T>(
ExecuteGentleWithPort<R> execution, {
WorkPriority priority = WorkPriority.immediately,
required void Function(T value) onMessage,
}) {
return _createCancelable<R>(
execution: execution,
priority: priority,
onMessage: (message) => onMessage(message as T),
);
}
void _createWorkers() {
for (var i = 0; i < _isolatesCount; i++) {
_pool.add(Worker());
}
}
Future<void> _initializeWorkers() async {
await Future.wait(_pool.map((e) => e.initialize()));
}
Cancelable<R> _createCancelable<R>({
required Function execution,
WorkPriority priority = WorkPriority.immediately,
void Function(Object value)? onMessage,
}) {
if (_nextTaskId + 1 == _maxId) {
_nextTaskId = _minId;
}
final id = _nextTaskId.toString();
_nextTaskId++;
late final Task<R> task;
final completer = Completer<R>();
if (execution is Execute<R>) {
task = TaskRegular<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteWithPort<R>) {
task = TaskWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
} else if (execution is ExecuteGentle<R>) {
task = TaskGentle<R>(id: id, workPriority: priority, execution: execution, completer: completer);
} else if (execution is ExecuteGentleWithPort<R>) {
task = TaskGentleWithPort<R>(
id: id,
workPriority: priority,
execution: execution,
completer: completer,
onMessage: onMessage!,
);
}
_queue.add(task);
_schedule();
logTaskAdded(task.id);
return Cancelable(completer: task.completer, onCancel: () => _cancel(task));
}
Future<void> _ensureWorkersInitialized() async {
if (_pool.isEmpty) {
_createWorkers();
if (!_dynamicSpawning) {
await _initializeWorkers();
final poolSize = _pool.length;
final queueSize = _queue.length;
for (int i = 0; i <= min(poolSize, queueSize); i++) {
_schedule();
}
}
}
if (_pool.every((worker) => worker.taskId != null)) {
return;
}
if (_dynamicSpawning) {
final freeWorker = _pool.firstWhereOrNull(
(worker) => worker.taskId == null && !worker.initialized && !worker.initializing,
);
await freeWorker?.initialize();
_schedule();
}
}
void _schedule() {
final availableWorker = _pool.firstWhereOrNull((worker) => worker.taskId == null && worker.initialized);
if (availableWorker == null) {
_ensureWorkersInitialized();
return;
}
if (_queue.isEmpty) return;
final task = _queue.removeFirst();
availableWorker
.work(task)
.then(
(value) {
//could be completed already by cancel and it is normal.
//Assuming that worker finished with error and cleaned gracefully
task.complete(value, null, null);
},
onError: (error, st) {
task.complete(null, error, st);
},
)
.whenComplete(() {
if (_dynamicSpawning && _queue.isEmpty) availableWorker.kill();
_schedule();
});
}
@override
void _cancel(Task task) {
task.cancel();
_queue.remove(task);
final targetWorker = _pool.firstWhereOrNull((worker) => worker.taskId == task.id);
if (task is Gentle) {
targetWorker?.cancelGentle();
} else {
targetWorker?.kill();
if (!_dynamicSpawning) targetWorker?.initialize();
}
super._cancel(task);
}
}

View File

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

View File

@@ -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].

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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',

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_settings:
dependency: "direct main"
description:
name: app_settings
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
url: "https://pub.dev"
source: hosted
version: "6.1.1"
archive:
dependency: transitive
description:
@@ -45,10 +53,10 @@ packages:
dependency: transitive
description:
name: args
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.7.0"
async:
dependency: "direct main"
description:
@@ -77,10 +85,10 @@ packages:
dependency: "direct main"
description:
name: background_downloader
sha256: "9ed74c55750932178f6989ba8a659687c2a102e05b70f561a1b3f047a5dda790"
sha256: a22acfa37aa06ba5cfe6eb7b1aa700c78af64770ff450c73dd3d279d7c37d4ac
url: "https://pub.dev"
source: hosted
version: "9.2.5"
version: "9.2.6"
bonsoir:
dependency: transitive
description:
@@ -437,10 +445,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: "49413c8ca514dea7633e8def233b25efdf83ec8522955cc2c0e3ad802927e7c6"
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33
url: "https://pub.dev"
source: hosted
version: "12.1.0"
version: "12.2.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@@ -469,26 +477,26 @@ packages:
dependency: "direct main"
description:
name: drift_flutter
sha256: "0cadbf3b8733409a6cf61d18ba2e94e149df81df7de26f48ae0695b48fd71922"
sha256: b52bd710f809db11e25259d429d799d034ba1c5224ce6a73fe8419feb980d44c
url: "https://pub.dev"
source: hosted
version: "0.2.4"
version: "0.2.6"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d
sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
version: "1.8.1"
easy_localization:
dependency: "direct main"
description:
name: easy_localization
sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12"
sha256: "2ccdf9db8fe4d9c5a75c122e6275674508fd0f0d49c827354967b8afcc56bbed"
url: "https://pub.dev"
source: hosted
version: "3.0.7+1"
version: "3.0.8"
easy_logger:
dependency: transitive
description:
@@ -586,10 +594,10 @@ packages:
dependency: "direct main"
description:
name: flutter_displaymode
sha256: "42c5e9abd13d28ed74f701b60529d7f8416947e58256e6659c5550db719c57ef"
sha256: ecd44b1e902b0073b42ff5b55bf283f38e088270724cdbb7f7065ccf54aa60a8
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.7.0"
flutter_driver:
dependency: transitive
description: flutter
@@ -599,18 +607,18 @@ packages:
dependency: "direct main"
description:
name: flutter_hooks
sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d
sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42"
url: "https://pub.dev"
source: hosted
version: "0.21.2"
version: "0.21.3+1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.3"
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -652,10 +660,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: edb09c35ee9230c4b03f13dd45bb3a276d0801865f0a4650b7e2a3bba61a803a
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
url: "https://pub.dev"
source: hosted
version: "2.4.5"
version: "2.4.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -724,10 +732,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b
sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
url: "https://pub.dev"
source: hosted
version: "2.0.17"
version: "2.2.1"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -737,10 +745,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84
sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "4.0.0"
flutter_web_auth_2:
dependency: "direct main"
description:
@@ -791,14 +799,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
geoclue:
dependency: transitive
description:
name: geoclue
sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f
url: "https://pub.dev"
source: hosted
version: "0.1.1"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: e7ebfa04ce451daf39b5499108c973189a71a919aa53c1204effda1c5b93b822
sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516"
url: "https://pub.dev"
source: hosted
version: "14.0.0"
version: "14.0.2"
geolocator_android:
dependency: transitive
description:
@@ -815,6 +831,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.9"
geolocator_linux:
dependency: transitive
description:
name: geolocator_linux
sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3
url: "https://pub.dev"
source: hosted
version: "0.2.3"
geolocator_platform_interface:
dependency: transitive
description:
@@ -855,14 +879,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
gsettings:
dependency: transitive
description:
name: gsettings
sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c"
url: "https://pub.dev"
source: hosted
version: "0.2.8"
home_widget:
dependency: "direct main"
description:
name: home_widget
sha256: ad9634ef5894f3bac73f04d59e2e5151a39798f49985399fd928dadc828d974a
sha256: "908d033514a981f829fd98213909e11a428104327be3b422718aa643ac9d084a"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.8.1"
hooks_riverpod:
dependency: "direct main"
description:
@@ -883,18 +915,18 @@ packages:
dependency: transitive
description:
name: html
sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.5"
version: "0.15.6"
http:
dependency: "direct main"
description:
name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.5.0"
http_multi_server:
dependency: transitive
description:
@@ -915,74 +947,74 @@ packages:
dependency: transitive
description:
name: image
sha256: "13d3349ace88f12f4a0d175eb5c12dcdd39d35c4c109a8a13dfeb6d0bd9e31c3"
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
version: "4.5.4"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.2.0"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca"
url: "https://pub.dev"
source: hosted
version: "0.8.12+22"
version: "0.8.13+5"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
version: "3.1.0"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
sha256: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58
url: "https://pub.dev"
source: hosted
version: "0.8.12+2"
version: "0.8.13+1"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa"
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
version: "2.11.0"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
version: "0.2.2"
immich_mobile_immich_lint:
dependency: "direct dev"
description:
@@ -1313,10 +1345,10 @@ packages:
dependency: "direct main"
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.3"
path_provider_linux:
dependency: transitive
description:
@@ -1393,34 +1425,34 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "7.0.1"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
sha256: "0bc7548fd3111eb93a3b0abf1c57364e40aeda32512c100085a48dade60e574f"
sha256: a0d9a7a9bc35eda02d33766412bde6d883a8b0acb86bbe37dac5f691a0894e8a
url: "https://pub.dev"
source: hosted
version: "3.6.4"
version: "3.7.1"
pigeon:
dependency: "direct dev"
description:
name: pigeon
sha256: b65acb352dc5a5f8615d074a83419388cbcc249f07c6d8c78b5bc16680a55dda
sha256: "0045b172d1da43c40cb3f58e80e04b50a65cba20b8b70dc880af04181f7758da"
url: "https://pub.dev"
source: hosted
version: "26.0.0"
version: "26.0.2"
pinput:
dependency: "direct main"
description:
name: pinput
sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a"
sha256: c41f42ee301505ae2375ec32871c985d3717bf8aee845620465b286e0140aad2
url: "https://pub.dev"
source: hosted
version: "5.0.1"
version: "5.0.2"
platform:
dependency: transitive
description:
@@ -1569,18 +1601,18 @@ packages:
dependency: "direct main"
description:
name: share_handler
sha256: "76575533be04df3fecbebd3c5b5325a8271b5973131f8b8b0ab8490c395a5d37"
sha256: "0a6d007f0e44fbee27164adcd159ecbc88238864313f4e5c58161cae2180328d"
url: "https://pub.dev"
source: hosted
version: "0.0.22"
version: "0.0.25"
share_handler_android:
dependency: transitive
description:
name: share_handler_android
sha256: "124dcc914fb7ecd89076d3dc28435b98fe2129a988bf7742f7a01dcb66a95667"
sha256: caf555b933dc72783aa37fef75688c7b86bd6f7bc17d80fbf585bc42f123cc8d
url: "https://pub.dev"
source: hosted
version: "0.0.9"
version: "0.0.11"
share_handler_ios:
dependency: transitive
description:
@@ -1934,10 +1966,10 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.1"
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
@@ -2022,10 +2054,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.16"
version: "1.1.19"
vector_math:
dependency: transitive
description:
@@ -2046,18 +2078,18 @@ packages:
dependency: "direct main"
description:
name: wakelock_plus
sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e"
sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b"
url: "https://pub.dev"
source: hosted
version: "1.2.10"
version: "1.3.3"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a"
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.3.0"
watcher:
dependency: transitive
description:
@@ -2126,10 +2158,10 @@ packages:
dependency: "direct main"
description:
name: worker_manager
sha256: "086ed63e9b36266e851404ca90fd44e37c0f4c9bbf819e5f8d7c87f9741c0591"
sha256: "1bce9f894a0c187856f5fc0e150e7fe1facce326f048ca6172947754dac3d4f3"
url: "https://pub.dev"
source: hosted
version: "7.2.3"
version: "7.2.7"
xdg_directories:
dependency: transitive
description:
@@ -2142,10 +2174,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
version: "6.6.1"
xxh3:
dependency: transitive
description:
@@ -2163,5 +2195,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.6"

View File

@@ -9,41 +9,42 @@ environment:
flutter: 3.35.6
dependencies:
async: ^2.11.0
app_settings: ^6.1.1
async: ^2.13.0
auto_route: ^9.2.0
background_downloader: ^9.2.5
background_downloader: ^9.2.6
cached_network_image: ^3.4.1
cancellation_token_http: ^2.1.0
cast: ^2.1.0
collection: ^1.18.0
collection: ^1.19.1
connectivity_plus: ^6.1.3
crop_image: ^1.0.16
crypto: ^3.0.6
device_info_plus: ^12.0.0
device_info_plus: ^12.2.0
# DB
drift: ^2.23.1
drift_flutter: ^0.2.4
dynamic_color: ^1.7.0
easy_localization: ^3.0.7+1
drift: ^2.26.0
drift_flutter: ^0.2.6
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
ffi: ^2.1.4
file_picker: ^8.0.0+1
flutter:
sdk: flutter
flutter_cache_manager: ^3.4.1
flutter_displaymode: ^0.6.0
flutter_hooks: ^0.21.2
flutter_displaymode: ^0.7.0
flutter_hooks: ^0.21.3+1
flutter_local_notifications: ^17.2.1+2
flutter_secure_storage: ^9.2.4
flutter_svg: ^2.0.17
flutter_udid: ^3.0.0
flutter_svg: ^2.2.1
flutter_udid: ^4.0.0
flutter_web_auth_2: ^5.0.0-alpha.0
fluttertoast: ^8.2.12
geolocator: ^14.0.0
home_widget: ^0.8.0
geolocator: ^14.0.2
home_widget: ^0.8.1
hooks_riverpod: ^2.6.1
http: ^1.3.0
image_picker: ^1.1.2
intl: ^0.20.0
http: ^1.5.0
image_picker: ^1.2.0
intl: ^0.20.2
isar:
git:
url: https://github.com/immich-app/isar
@@ -65,37 +66,37 @@ dependencies:
package_info_plus: ^8.3.0
path: ^1.9.1
path_provider: ^2.1.5
path_provider_foundation: ^2.4.1
path_provider_foundation: ^2.4.3
permission_handler: ^11.4.0
photo_manager: ^3.6.4
pinput: ^5.0.1
photo_manager: ^3.7.1
pinput: ^5.0.2
punycode: ^1.0.0
riverpod_annotation: ^2.6.1
scroll_date_picker: ^3.8.0
scrollable_positioned_list: ^0.3.8
share_handler: ^0.0.22
share_handler: ^0.0.25
share_plus: ^10.1.4
sliver_tools: ^0.2.12
socket_io_client: ^2.0.3+1
stream_transform: ^2.1.1
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.1
url_launcher: ^6.3.2
uuid: ^4.5.1
wakelock_plus: ^1.2.10
worker_manager: ^7.2.3
wakelock_plus: ^1.3.0
worker_manager: ^7.2.7
dev_dependencies:
auto_route_generator: ^9.0.0
build_runner: ^2.4.8
custom_lint: ^0.7.5
# Drift generator
drift_dev: ^2.23.1
fake_async: ^1.3.1
drift_dev: ^2.26.0
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.3
flutter_launcher_icons: ^0.14.4
flutter_lints: ^5.0.0
flutter_native_splash: ^2.4.5
flutter_native_splash: ^2.4.7
flutter_test:
sdk: flutter
immich_mobile_immich_lint:
@@ -109,7 +110,7 @@ dev_dependencies:
path: packages/isar_generator/
mocktail: ^1.0.4
# Type safe platform code
pigeon: ^26.0.0
pigeon: ^26.0.2
riverpod_generator: ^2.6.1
riverpod_lint: ^2.6.1

View File

@@ -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",

View File

@@ -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
plugins/.gitignore vendored
View File

@@ -1,2 +0,0 @@
node_modules
dist

View File

@@ -1,26 +0,0 @@
Copyright 2024, The Extism Authors.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,12 +0,0 @@
const esbuild = require('esbuild');
esbuild
.build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: true,
minify: false, // might want to use true for production build
format: 'cjs', // needs to be CJS for now
target: ['es2020'] // don't go over es2020 because quickjs doesn't support it
})

View File

@@ -1,443 +0,0 @@
{
"name": "js-pdk-template",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "js-pdk-template",
"version": "1.0.0",
"license": "BSD-3-Clause",
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"esbuild": "^0.19.6",
"typescript": "^5.3.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@extism/js-pdk": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.0.1.tgz",
"integrity": "sha512-YJWfHGeOuJnQw4V8NPNHvbSr6S8iDd2Ga6VEukwlRP7tu62ozTxIgokYw8i+rajD/16zz/gK0KYARBpm2qPAmQ==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
}
},
"node_modules/typescript": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -1,19 +0,0 @@
{
"name": "plugins",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
"author": "",
"license": "BSD-3-Clause",
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"esbuild": "^0.19.6",
"typescript": "^5.3.2"
}
}

View File

@@ -1,9 +0,0 @@
declare module 'main' {
export function archiveAssetAction(): I32;
}
declare module 'extism:host' {
interface user {
updateAsset(ptr: PTR): I32;
}
}

View File

@@ -1,16 +0,0 @@
const { updateAsset } = Host.getFunctions();
export function archiveAssetAction() {
const event = JSON.parse(Host.inputString());
const ptr = Memory.fromString(
JSON.stringify({
id: event.asset.id,
visibility: 'archive',
})
);
updateAsset(ptr.offset);
ptr.free();
return 0;
}

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "es2020", // Specify ECMAScript target version
"module": "commonjs", // Specify module code generation
"lib": [
"es2020"
], // Specify a list of library files to be included in the compilation
"types": [
"@extism/js-pdk",
"./src/index.d.ts"
], // Specify a list of type definition files to be included in the compilation
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
"skipLibCheck": true, // Skip type checking of declaration files
"allowJs": true, // Allow JavaScript files to be compiled
"noEmit": true // Do not emit outputs (no .js or .d.ts files)
},
"include": [
"src/**/*.ts" // Include all TypeScript files in src directory
],
"exclude": [
"node_modules" // Exclude the node_modules directory
]
}

32
pnpm-lock.yaml generated
View File

@@ -299,23 +299,8 @@ importers:
specifier: ^5.3.3
version: 5.9.3
plugins:
devDependencies:
'@extism/js-pdk':
specifier: ^1.0.1
version: 1.1.1
esbuild:
specifier: ^0.19.6
version: 0.19.12
typescript:
specifier: ^5.3.2
version: 5.9.3
server:
dependencies:
'@extism/extism':
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@nestjs/bullmq':
specifier: ^11.0.1
version: 11.0.4(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(bullmq@5.61.0)
@@ -2540,12 +2525,6 @@ packages:
resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@extism/extism@2.0.0-rc13':
resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==}
'@extism/js-pdk@1.1.1':
resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==}
'@faker-js/faker@10.1.0':
resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
@@ -10969,9 +10948,6 @@ packages:
resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==}
engines: {node: '>= 0.4'}
urlpattern-polyfill@8.0.2:
resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==}
utf8-byte-length@1.0.5:
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
@@ -14032,12 +14008,6 @@ snapshots:
'@eslint/core': 0.16.0
levn: 0.4.1
'@extism/extism@2.0.0-rc13': {}
'@extism/js-pdk@1.1.1':
dependencies:
urlpattern-polyfill: 8.0.2
'@faker-js/faker@10.1.0': {}
'@fig/complete-commander@3.2.0(commander@11.1.0)':
@@ -23955,8 +23925,6 @@ snapshots:
punycode: 1.4.1
qs: 6.14.0
urlpattern-polyfill@8.0.2: {}
utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {}

View File

@@ -4,7 +4,6 @@ packages:
- e2e
- open-api/typescript-sdk
- server
- plugins
- web
- .github
ignoredBuiltDependencies:

View File

@@ -34,7 +34,6 @@
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",

View File

@@ -19,7 +19,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service';
@@ -56,7 +55,6 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
private jobService: JobService,
private telemetryRepository: TelemetryRepository,
private authService: AuthService,
private userRepository: UserRepository,
) {
logger.setAppName(this.worker);
}

View File

@@ -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(

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,

View File

@@ -236,6 +236,8 @@ export enum Permission {
AdminUserUpdate = 'adminUser.update',
AdminUserDelete = 'adminUser.delete',
AdminSessionRead = 'adminSession.read',
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}

View File

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

View File

@@ -23,6 +23,7 @@ select
"session"."id",
"session"."updatedAt",
"session"."pinExpiresAt",
"session"."appVersion",
(
select
to_json(obj)

View File

@@ -17,7 +17,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { JobItem, JobSource } from 'src/types';
@@ -33,11 +33,6 @@ type Item<T extends EmitEvent> = {
label: string;
};
type AssetCreateV1 = {
id: string;
ownerId: string;
};
type EventMap = {
// app events
AppBootstrap: [];
@@ -58,7 +53,6 @@ type EventMap = {
AlbumInvite: [{ id: string; userId: string }];
// asset events
AssetCreate: [{ asset: AssetCreateV1 }];
AssetTag: [{ assetId: string }];
AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }];
@@ -72,8 +66,19 @@ type EventMap = {
AssetDeleteAll: [{ assetIds: string[]; userId: string }];
AssetRestoreAll: [{ assetIds: string[]; userId: string }];
/** a worker receives a job and emits this event to run it */
JobRun: [QueueName, JobItem];
/** job pre-hook */
JobStart: [QueueName, JobItem];
JobFailed: [{ job: JobItem; error: Error | any }];
/** job post-hook */
JobComplete: [QueueName, JobItem];
/** job finishes without error */
JobSuccess: [JobSuccessEvent];
/** job finishes with error */
JobError: [JobErrorEvent];
// queue events
QueueStart: [QueueStartEvent];
// session events
SessionDelete: [{ sessionId: string }];
@@ -88,11 +93,43 @@ type EventMap = {
// user events
UserSignup: [{ notify: boolean; id: string; password?: string }];
UserCreate: [UserEvent];
/** user is soft deleted */
UserTrash: [UserEvent];
/** user is permanently deleted */
UserDelete: [UserEvent];
UserRestore: [UserEvent];
// websocket events
WebsocketConnect: [{ userId: string }];
};
type JobSuccessEvent = { job: JobItem; response?: JobStatus };
type JobErrorEvent = { job: JobItem; error: Error | any };
type QueueStartEvent = {
name: QueueName;
};
type UserEvent = {
name: string;
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
status: UserStatus;
email: string;
profileImagePath: string;
isAdmin: boolean;
shouldChangePassword: boolean;
avatarColor: UserAvatarColor | null;
oauthId: string;
storageLabel: string | null;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number;
profileChangedAt: Date;
};
export const serverEvents = ['ConfigUpdate'] as const;
export type ServerEvents = (typeof serverEvents)[number];

View File

@@ -89,7 +89,7 @@ export class JobRepository {
this.logger.debug(`Starting worker for queue: ${queueName}`);
this.workers[queueName] = new Worker(
queueName,
(job) => this.eventRepository.emit('JobStart', queueName, job as JobItem),
(job) => this.eventRepository.emit('JobRun', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
);
}

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;`.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({ nullable: true })
appVersion!: string | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;

View File

@@ -426,9 +426,6 @@ export class AssetMediaService extends BaseService {
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.eventRepository.emit('AssetCreate', { asset });
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
return asset;

View File

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

View File

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

View File

@@ -198,8 +198,8 @@ export class BaseService {
}
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
const exists = await this.userRepository.getByEmail(dto.email);
if (exists) {
throw new BadRequestException('User exists');
}
@@ -218,7 +218,10 @@ export class BaseService {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
}
this.telemetryRepository.api.addToGauge(`immich.users.total`, 1);
return this.userRepository.create(payload);
const user = await this.userRepository.create(payload);
await this.eventRepository.emit('UserCreate', user);
return user;
}
}

View File

@@ -22,7 +22,6 @@ import { NotificationAdminService } from 'src/services/notification-admin.servic
import { NotificationService } from 'src/services/notification.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { PluginService } from 'src/services/plugin.service';
import { SearchService } from 'src/services/search.service';
import { ServerService } from 'src/services/server.service';
import { SessionService } from 'src/services/session.service';
@@ -35,6 +34,7 @@ import { SyncService } from 'src/services/sync.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service';
import { TelemetryService } from 'src/services/telemetry.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserAdminService } from 'src/services/user-admin.service';
@@ -67,7 +67,6 @@ export const services = [
NotificationAdminService,
PartnerService,
PersonService,
PluginService,
SearchService,
ServerService,
SessionService,
@@ -80,6 +79,7 @@ export const services = [
SystemConfigService,
SystemMetadataService,
TagService,
TelemetryService,
TimelineService,
TrashService,
UserAdminService,

View File

@@ -222,18 +222,16 @@ describe(JobService.name, () => {
});
});
describe('onJobStart', () => {
describe('onJobRun', () => {
it('should process a successful job', async () => {
mocks.job.run.mockResolvedValue(JobStatus.Success);
await sut.onJobStart(QueueName.BackgroundTask, {
name: JobName.FileDelete,
data: { files: ['path/to/file'] },
});
const job: JobItem = { name: JobName.FileDelete, data: { files: ['path/to/file'] } };
await sut.onJobRun(QueueName.BackgroundTask, job);
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1);
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1);
expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.file_delete.success', 1);
expect(mocks.event.emit).toHaveBeenCalledWith('JobStart', QueueName.BackgroundTask, job);
expect(mocks.event.emit).toHaveBeenCalledWith('JobSuccess', { job, response: JobStatus.Success });
expect(mocks.event.emit).toHaveBeenCalledWith('JobComplete', QueueName.BackgroundTask, job);
expect(mocks.logger.error).not.toHaveBeenCalled();
});
@@ -300,7 +298,7 @@ describe(JobService.name, () => {
mocks.job.run.mockResolvedValue(JobStatus.Success);
await sut.onJobStart(QueueName.BackgroundTask, item);
await sut.onJobRun(QueueName.BackgroundTask, item);
if (jobs.length > 1) {
expect(mocks.job.queueAll).toHaveBeenCalledWith(
@@ -317,7 +315,7 @@ describe(JobService.name, () => {
it(`should not queue any jobs when ${item.name} fails`, async () => {
mocks.job.run.mockResolvedValue(JobStatus.Failed);
await sut.onJobStart(QueueName.BackgroundTask, item);
await sut.onJobRun(QueueName.BackgroundTask, item);
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});

View File

@@ -1,6 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';
import { snakeCase } from 'lodash';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
@@ -186,7 +185,7 @@ export class JobService extends BaseService {
throw new BadRequestException(`Job is already running`);
}
this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1);
await this.eventRepository.emit('QueueStart', { name });
switch (name) {
case QueueName.VideoConversion: {
@@ -243,21 +242,19 @@ export class JobService extends BaseService {
}
}
@OnEvent({ name: 'JobStart' })
async onJobStart(...[queueName, job]: ArgsOf<'JobStart'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
@OnEvent({ name: 'JobRun' })
async onJobRun(...[queueName, job]: ArgsOf<'JobRun'>) {
try {
const status = await this.jobRepository.run(job);
const jobMetric = `immich.jobs.${snakeCase(job.name)}.${status}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
if (status === JobStatus.Success || status == JobStatus.Skipped) {
await this.eventRepository.emit('JobStart', queueName, job);
const response = await this.jobRepository.run(job);
await this.eventRepository.emit('JobSuccess', { job, response });
if (response && typeof response === 'string' && [JobStatus.Success, JobStatus.Skipped].includes(response)) {
await this.onDone(job);
}
} catch (error: Error | any) {
await this.eventRepository.emit('JobFailed', { job, error });
await this.eventRepository.emit('JobError', { job, error });
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
await this.eventRepository.emit('JobComplete', queueName, job);
}
}
@@ -424,11 +421,6 @@ export class JobService extends BaseService {
}
break;
}
case JobName.UserDelete: {
this.eventRepository.clientBroadcast('on_user_delete', item.data.id);
break;
}
}
}
}

View File

@@ -78,8 +78,8 @@ export class NotificationService extends BaseService {
await this.notificationRepository.cleanup();
}
@OnEvent({ name: 'JobFailed' })
async onJobFailed({ job, error }: ArgOf<'JobFailed'>) {
@OnEvent({ name: 'JobError' })
async onJobError({ job, error }: ArgOf<'JobError'>) {
const admin = await this.userRepository.getAdmin();
if (!admin) {
return;
@@ -202,6 +202,11 @@ export class NotificationService extends BaseService {
}
}
@OnEvent({ name: 'UserDelete' })
onUserDelete({ id }: ArgOf<'UserDelete'>) {
this.eventRepository.clientBroadcast('on_user_delete', id);
}
@OnEvent({ name: 'AlbumUpdate' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);

View File

@@ -1,31 +0,0 @@
import { CurrentPlugin, newPlugin } from '@extism/extism';
import { Updateable } from 'kysely';
import { resolve } from 'node:path';
import { OnEvent } from 'src/decorators';
import { ArgOf } from 'src/repositories/event.repository';
import { AssetTable } from 'src/schema/tables/asset.table';
import { BaseService } from 'src/services/base.service';
export class PluginService extends BaseService {
@OnEvent({ name: 'AssetCreate' })
async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
console.log(`PluginService.handleAssetCreate: ${asset.id}`);
const corePath = resolve('../plugins/dist/plugin.wasm');
const plugin = await newPlugin(corePath, {
useWasi: true,
functions: {
'extism:host/user': {
updateAsset: (cp: CurrentPlugin, offs: bigint) => this.updateAsset(JSON.parse(cp.read(offs)!.text())),
},
},
});
const event = { asset };
await plugin.call('archiveAssetAction', JSON.stringify(event));
}
async updateAsset(asset: Updateable<AssetTable> & { id: string }) {
console.log(`Updating asset ${asset.id} -- ${JSON.stringify({ ...asset, id: undefined })}`);
await this.assetRepository.update(asset);
}
}

View File

@@ -0,0 +1,59 @@
import { snakeCase } from 'lodash';
import { OnEvent } from 'src/decorators';
import { ImmichWorker, JobStatus } from 'src/enum';
import { ArgOf, ArgsOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
export class TelemetryService extends BaseService {
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
async onBootstrap(): Promise<void> {
const userCount = await this.userRepository.getCount();
this.telemetryRepository.api.addToGauge('immich.users.total', userCount);
}
@OnEvent({ name: 'UserCreate' })
onUserCreate() {
this.telemetryRepository.api.addToGauge(`immich.users.total`, 1);
}
@OnEvent({ name: 'UserTrash' })
onUserTrash() {
this.telemetryRepository.api.addToGauge(`immich.users.total`, -1);
}
@OnEvent({ name: 'UserRestore' })
onUserRestore() {
this.telemetryRepository.api.addToGauge(`immich.users.total`, 1);
}
@OnEvent({ name: 'JobStart' })
onJobStart(...[queueName]: ArgsOf<'JobStart'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
}
@OnEvent({ name: 'JobSuccess' })
onJobSuccess({ job, response }: ArgOf<'JobSuccess'>) {
if (response && Object.values(JobStatus).includes(response as JobStatus)) {
const jobMetric = `immich.jobs.${snakeCase(job.name)}.${response}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
}
}
@OnEvent({ name: 'JobError' })
onJobError({ job }: ArgOf<'JobError'>) {
const jobMetric = `immich.jobs.${snakeCase(job.name)}.${JobStatus.Failed}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
}
@OnEvent({ name: 'JobComplete' })
onJobComplete(...[queueName]: ArgsOf<'JobComplete'>) {
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
}
@OnEvent({ name: 'QueueStart' })
onQueueStart({ name }: ArgOf<'QueueStart'>) {
this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1);
}
}

View File

@@ -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,
@@ -102,7 +103,8 @@ export class UserAdminService extends BaseService {
const status = force ? UserStatus.Removing : UserStatus.Deleted;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
this.telemetryRepository.api.addToGauge(`immich.users.total`, -1);
await this.eventRepository.emit('UserTrash', user);
if (force) {
await this.jobRepository.queue({ name: JobName.UserDelete, data: { id: user.id, force } });
@@ -115,10 +117,15 @@ export class UserAdminService extends BaseService {
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
const user = await this.userRepository.restore(id);
this.telemetryRepository.api.addToGauge('immich.users.total', 1);
await this.eventRepository.emit('UserRestore', user);
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);

View File

@@ -3,14 +3,14 @@ import { Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { CacheControl, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { UserFindOptions } from 'src/repositories/user.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
@@ -213,12 +213,6 @@ export class UserService extends BaseService {
};
}
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
async onBootstrap(): Promise<void> {
const userCount = await this.userRepository.getCount();
this.telemetryRepository.api.addToGauge('immich.users.total', userCount);
}
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage();
@@ -234,17 +228,17 @@ export class UserService extends BaseService {
}
@OnJob({ name: JobName.UserDelete, queue: QueueName.BackgroundTask })
async handleUserDelete({ id, force }: JobOf<JobName.UserDelete>): Promise<JobStatus> {
async handleUserDelete({ id, force }: JobOf<JobName.UserDelete>) {
const config = await this.getConfig({ withCache: false });
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
return JobStatus.Failed;
return;
}
// just for extra protection here
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return JobStatus.Skipped;
return;
}
this.logger.log(`Deleting user: ${user.id}`);
@@ -266,7 +260,7 @@ export class UserService extends BaseService {
await this.albumRepository.deleteAll(user.id);
await this.userRepository.delete(user, true);
return JobStatus.Success;
await this.eventRepository.emit('UserDelete', user);
}
private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean {

View File

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

View File

@@ -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 => {

View File

@@ -44,7 +44,8 @@ beforeAll(async () => {
describe(AuthService.name, () => {
describe('adminSignUp', () => {
it(`should sign up the admin`, async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
await expect(sut.adminSignUp(dto)).resolves.toEqual(

View File

@@ -3,10 +3,10 @@ import { DateTime } from 'luxon';
import { ImmichEnvironment, JobName, JobStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { UserService } from 'src/services/user.service';
@@ -22,7 +22,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(UserService, {
database: db || defaultDatabase,
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
mock: [LoggingRepository, JobRepository, TelemetryRepository],
mock: [LoggingRepository, JobRepository, EventRepository],
});
};
@@ -35,7 +35,8 @@ beforeAll(async () => {
describe(UserService.name, () => {
describe('create', () => {
it('should create a user', async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual(
expect.objectContaining({ name: user.name, email: user.email }),
@@ -43,14 +44,16 @@ describe(UserService.name, () => {
});
it('should reject user with duplicate email', async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
});
it('should not return password', async () => {
const { sut } = setup();
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();

View File

@@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
userId: newUuid(),
pinExpiresAt: newDate(),
isPendingSyncReset: false,
appVersion: session.appVersion ?? null,
...session,
});

View File

@@ -20,7 +20,7 @@
<AppShellHeader>
<NavigationBar showUploadButton={false} noBorder />
</AppShellHeader>
<AppShellSidebar bind:open={sidebarStore.isOpen}>
<AppShellSidebar bind:open={sidebarStore.isOpen} class="border-none shadow-none">
<AdminSidebar />
</AppShellSidebar>

View File

@@ -6,22 +6,26 @@
import { t } from 'svelte-i18n';
</script>
<HStack wrap>
<p>{$t('mobile_app_download_onboarding_note')}</p>
<HStack>
<Button
size="large"
size="medium"
shape="semi-round"
fullWidth
onclick={() => modalManager.show(AppDownloadModal, {})}
leadingIcon={mdiCellphoneArrowDownVariant}
>
{$t('app_stores')}
</Button>
<Button
size="medium"
shape="semi-round"
fullWidth
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
leadingIcon={mdiLinkEdit}
>
{$t('obtainium_configurator')}
</Button>
<Button
size="large"
shape="semi-round"
onclick={() => modalManager.show(AppDownloadModal, {})}
leadingIcon={mdiCellphoneArrowDownVariant}
>
{$t('app_download_links')}
</Button>
</HStack>
<p>{$t('mobile_app_download_onboarding_note')}</p>

View File

@@ -40,7 +40,11 @@
};
</script>
<div in:fade={{ duration: 250 }} out:fade={{ duration: 100 }} class="flex flex-col rounded-lg text-xs p-2 gap-1">
<div
in:fade={{ duration: 250 }}
out:fade={{ duration: 100 }}
class="flex flex-col rounded-xl text-xs p-2 gap-1 border border-gray-300 dark:border-subtle bg-primary/10"
>
<div class="flex items-center gap-2">
<div class="flex items-center justify-center">
{#if uploadAsset.state === UploadState.PENDING}
@@ -91,12 +95,13 @@
</div>
{#if uploadAsset.state === UploadState.STARTED}
<div class="text-black relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 dark:bg-gray-700">
<div class="h-[15px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
<p class="absolute top-0 h-full w-full text-center text-primary text-[10px]">
{#if uploadAsset.message}
<div class="text-black relative mt-[5px] h-[18px] w-full rounded-md bg-gray-300 dark:bg-gray-700">
<div class="h-[18px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
<p class="absolute top-0.5 h-full w-full text-center text-white text-[10px]">
{#if uploadAsset.message === $t('asset_hashing')}
{uploadAsset.message}
{:else}
{uploadAsset.message}
{uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s
{/if}
</p>

View File

@@ -52,7 +52,7 @@
{#if showDetail}
<div
in:scale={{ duration: 250, easing: quartInOut }}
class="w-[300px] rounded-lg border bg-gray-100 p-4 text-sm shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-white"
class="w-[325px] rounded-xl border border-gray-200 dark:border-subtle p-4 text-sm shadow-xs bg-subtle"
>
<div class="place-item-center mb-4 flex justify-between">
<div class="flex flex-col gap-1">

View File

@@ -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"

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge } from '@immich/ui';
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
@@ -9,35 +9,29 @@
<Modal title={$t('app_download_links')} size="large" {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="fdroid-link">
F-Droid
</label>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="pt-2 pr-10" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="play-store-link">
Google Play
</label>
<div class="sm:grid sm:grid-cols-2 gap-5">
<div class="flex flex-col place-items-start">
<Text>Google Play</Text>
<a
href="https://play.google.com/store/apps/details?id=app.alextran.immich"
target="_blank"
id="play-store-link"
>
<img alt="Get it on Google Play" src={playStoreBadge} />
<img class="w-[200px] mt-2" alt="Get it on Google Play" src={playStoreBadge} />
</a>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="app-store-link">
App Store
</label>
<div class="flex flex-col place-items-start">
<Text>App Store</Text>
<a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
<img class="pt-2 pr-5" alt="Download on the App Store" src={appStoreBadge} width="90%" />
<img class="w-[200px] mt-2" alt="Download on the App Store" src={appStoreBadge} />
</a>
</div>
<div class="flex flex-col place-items-start">
<Text>F-Droid</Text>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="w-[200px] mt-2" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
</div>

View File

@@ -1,14 +1,13 @@
<script lang="ts">
import { ConfirmModal, Input } from '@immich/ui';
import { ConfirmModal, Field, Textarea } from '@immich/ui';
import { mdiText } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
onClose: (description?: string) => void;
}
};
let { onClose }: Props = $props();
let description = $state('');
</script>
@@ -20,11 +19,8 @@
onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
>
{#snippet promptSnippet()}
<div class="flex flex-col text-start gap-2">
<div class="flex flex-col">
<label for="description">{$t('description')}</label>
<Input class="immich-form-input" id="description" bind:value={description} />
</div>
</div>
<Field label={$t('description')}>
<Textarea bind:value={description} grow />
</Field>
{/snippet}
</ConfirmModal>

View File

@@ -4,7 +4,7 @@
import { SettingInputFieldType } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { createApiKey, Permission } from '@immich/sdk';
import { Button, Modal, ModalBody, obtainiumBadge } from '@immich/ui';
import { Button, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
let inputUrl = $state(location.origin);
let inputApiKey = $state('');
@@ -31,64 +31,53 @@
let { onClose }: Props = $props();
</script>
<Modal title={$t('obtainium_configurator')} size="large" {onClose}>
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
<div>
<label
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
for="obtainium-configurator"
>
Obtainium
</label>
<div id="obtainium-configurator">
<form>
<div class="mt-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
</div>
<div class="mt-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('api_key')}
bind:value={inputApiKey}
/>
</div>
<div class="">
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
</div>
<div class="mt-2">
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
</div>
</form>
<div>
<Text color="muted" size="small">
{$t('obtainium_configurator_instructions')}
</Text>
<form class="mt-4">
<div class="mt-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
</div>
</div>
<div class="content-center">
{#if inputUrl && inputApiKey && archVariant}
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 pr-5" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
{:else}
<p class="immich-form-label pb-2 text-sm" id="obtainium-link">
{$t('obtainium_configurator_instructions')}
</p>
{/if}
</div>
<div class="mt-2 flex gap-2 place-items-center place-content-center">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('api_key')} bind:value={inputApiKey} />
<div class="translate-y-[3px]">
<Button size="small" onclick={() => handleCreate()}>{$t('create_api_key')}</Button>
</div>
</div>
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
</form>
{#if inputUrl && inputApiKey && archVariant}
<div class="content-center">
<hr />
<div class="flex place-items-center place-content-center">
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 pr-5 h-[80px]" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
</div>
</div>
{/if}
</div>
</ModalBody>
</Modal>

View File

@@ -3,57 +3,51 @@
import { serverConfig } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteUserAdmin, type UserAdminResponseDto, type UserResponseDto } from '@immich/sdk';
import { Checkbox, ConfirmModal, Label } from '@immich/ui';
import { Alert, Checkbox, ConfirmModal, Field, Input, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
user: UserResponseDto;
onClose: (user?: UserAdminResponseDto) => void;
}
};
let { user, onClose }: Props = $props();
let forceDelete = $state(false);
let deleteButtonDisabled = $state(false);
let userIdInput: string = '';
let force = $state(false);
let email = $state('');
let disabled = $derived(force && email !== user.email);
const handleClose = async (confirmed: boolean) => {
if (!confirmed) {
onClose();
return;
}
const handleDeleteUser = async () => {
try {
const result = await deleteUserAdmin({
id: user.id,
userAdminDeleteDto: { force: forceDelete },
});
const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: { force } });
onClose(result);
} catch (error) {
handleError(error, $t('errors.unable_to_delete_user'));
}
};
const handleConfirm = (e: Event) => {
userIdInput = (e.target as HTMLInputElement).value;
deleteButtonDisabled = userIdInput != user.email;
};
</script>
<ConfirmModal
title={$t('delete_user')}
confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
onClose={(confirmed) => (confirmed ? handleDeleteUser() : onClose())}
disabled={deleteButtonDisabled}
confirmText={force ? $t('permanently_delete') : $t('delete')}
onClose={handleClose}
{disabled}
>
{#snippet promptSnippet()}
<div class="flex flex-col gap-4">
{#if forceDelete}
<p>
<Text>
{#if force}
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}>
{#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage>
</p>
{:else}
<p>
{:else}
<FormatMessage
key="admin.user_delete_delay"
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
@@ -62,34 +56,20 @@
<b>{message}</b>
{/snippet}
</FormatMessage>
</p>
{/if}
{/if}
</Text>
<div class="flex justify-center items-center gap-2">
<Checkbox
id="queue-user-deletion-checkbox"
color="secondary"
bind:checked={forceDelete}
onCheckedChange={() => (deleteButtonDisabled = forceDelete)}
/>
<div class="flex items-center gap-2">
<Checkbox id="queue-user-deletion-checkbox" color="secondary" bind:checked={force} />
<Label label={$t('admin.user_delete_immediately_checkbox')} for="queue-user-deletion-checkbox" />
</div>
{#if forceDelete}
<p class="text-danger">{$t('admin.force_delete_user_warning')}</p>
{#if force}
<Alert color="danger" icon={false}>{$t('admin.force_delete_user_warning')}</Alert>
<p class="immich-form-label text-sm" id="confirm-user-desc">
{$t('admin.confirm_email_below', { values: { email: user.email } })}
</p>
<input
class="immich-form-input w-full pb-2"
id="confirm-user-id"
aria-describedby="confirm-user-desc"
name="confirm-user-id"
type="text"
oninput={handleConfirm}
/>
<Field label={$t('admin.confirm_email_below', { values: { email: user.email } })}>
<Input bind:value={email} />
</Field>
{/if}
</div>
{/snippet}

View File

@@ -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-dark">No mobile devices</span>
{/each}
</Stack>
</div>
</CardBody>
</Card>
</div>
</Container>
</div>

View File

@@ -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'),
},

View File

@@ -90,7 +90,7 @@
component: OnboardingMobileApp,
role: OnboardingRole.USER,
title: $t('mobile_app'),
icon: mdiCellphoneArrowDownVariant, // or you can use mdiCellphone
icon: mdiCellphoneArrowDownVariant,
},
]);
@@ -167,7 +167,7 @@
style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
></div>
</div>
<div class="py-8 flex place-content-center place-items-center m-auto">
<div class="py-8 flex place-content-center place-items-center m-auto w-[min(100%,_800px)]">
<OnboardingCard
title={onboardingSteps[index].title}
icon={onboardingSteps[index].icon}