From b9f6c4aaf22fd032cf1cc4357db06fe9ca4e3abc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 Jul 2026 12:09:05 -0400 Subject: [PATCH 1/5] feat: password invalidate sessions (#30125) --- .../img/reset-admin-password.webp | Bin 9066 -> 0 bytes docs/docs/administration/server-commands.md | 1 + .../immich-admin/immich-admin.e2e-spec.ts | 4 ++++ server/src/commands/index.ts | 4 ++-- .../commands/reset-admin-password.command.ts | 20 +++++++++++++----- server/src/services/cli.service.spec.ts | 19 +++++++++++++++-- server/src/services/cli.service.ts | 10 +++++++-- 7 files changed, 47 insertions(+), 11 deletions(-) delete mode 100644 docs/docs/administration/img/reset-admin-password.webp diff --git a/docs/docs/administration/img/reset-admin-password.webp b/docs/docs/administration/img/reset-admin-password.webp deleted file mode 100644 index 5062d24a6b79f8515e8b78a8fd743965a171c34d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9066 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

YEkRBU8M`o~BezqD4f6Q~J^zvjGDAVO=oXDr!V) z27_txasRk759K3>Sft;0OiXC0^>s5sew*sT=+pW;XkWE20b}nz-{p2TprLXd8S9$| zTDYhJXbAyNI}zOJ_SUIb{V;WKH@9Er)wz$AQtXx+nT*(&>4(%DVd(@)!J#mr22+(XAJ;S@K4L95G8q)Yq_q!oig>64N&UbYKin;c%`}*%;asf zTL?~~3AJs|Wrf4Y)HtMb6?hs|N{!``48Q0|Uf~P%#^oc}>KuODSKm7+nz%4u7u7=U zmfRIl1QJb7x9_nljgF4|4V7EXn(dPEVyAVc`54lCJfUk6zA{f~*OaB7`lQupJM{%Sr{7sp+%E3u0Nr%_vfujT5d| z8R=7`!T3EN{_1JSRl@}I+2Ust1Q{{;N!Us}V&m2Tqj=4e4kXe_X>hFYAyD@}GF~`% z{19p3ew(?jVKFEBX2@{;WrwM(J@SHzB@zRvovAqL?;`j$xJug)4=?8J|IgLVis4wh crc{x#J5U5KxuQ_*Qb|g9NTmP(000000D7XF4FCWD diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 104c16c0a2..49bcf17700 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -35,6 +35,7 @@ Found Admin: - Email=admin@example.com - Name=Immich Admin ? Please choose a new password (optional) immich-is-cool +? Invalidate existing sessions? Yes The admin password has been updated. ``` diff --git a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts index 24699cda30..ad0e43ff14 100644 --- a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts +++ b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts @@ -65,6 +65,10 @@ describe(`immich-admin`, () => { child.stdout.on('data', (chunk) => { data += chunk; if (data.includes('Please choose a new password (optional)')) { + child.stdin.write('\n'); + } + + if (data.includes('Invalidate existing sessions?')) { child.stdin.end('\n'); } }); diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2a2dd1857d..0d03e4cd95 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -8,13 +8,13 @@ import { } from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ ResetAdminPasswordCommand, - PromptPasswordQuestions, + PromptPasswordResetQuestions, PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index e5dee49837..703f693326 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserAdminResponseDto) { + return (admin: UserAdminResponseDto) => { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => { - Email=${email} - Name=${name}`); - return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); }; }; @@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner { } } -@QuestionSet({ name: 'prompt-password' }) -export class PromptPasswordQuestions { +@QuestionSet({ name: 'prompt-password-reset' }) +export class PromptPasswordResetQuestions { @Question({ message: 'Please choose a new password (optional)', - name: 'password', + name: 'newPassword', }) parsePassword(value: string) { return value; } + + @Question({ + type: 'confirm', + message: 'Invalidate existing sessions?', + default: true, + name: 'invalidateSessions', + }) + parseInvalidate(value: boolean): boolean { + return value; + } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 347d9eef00..29c62d258f 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -37,7 +37,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); - const ask = vitest.fn().mockImplementation(() => {}); + const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -47,6 +47,7 @@ describe(CliService.name, () => { expect(ask).toHaveBeenCalled(); expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); + expect(mocks.session.invalidateAll).not.toHaveBeenCalled(); }); it('should use the supplied password', async () => { @@ -55,7 +56,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); - const ask = vitest.fn().mockResolvedValue('new-password'); + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -66,6 +67,20 @@ describe(CliService.name, () => { expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); + + it('should invalidate existing sessions when requested', async () => { + const admin = UserFactory.create({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); + mocks.session.invalidateAll.mockResolvedValue(void 0); + + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true }); + + await sut.resetAdminPassword(ask); + + expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id }); + }); }); describe('disablePasswordLogin', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 23d70b6860..f37353d7a1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -58,18 +58,24 @@ export class CliService extends BaseService { return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { + async resetAdminPassword( + ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>, + ) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUserAdmin(admin)); + const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); + if (invalidateSessions) { + await this.sessionRepository.invalidateAll({ userId: admin.id }); + } + return { admin, password, provided: !!providedPassword }; } From a7f1d495c6cd3a5f51f922ad6a808e2cc774a8c1 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Jul 2026 11:44:14 -0500 Subject: [PATCH 2/5] fix: wrong corner radius of recently added link (#30140) --- mobile/lib/presentation/pages/search/drift_search.page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 2ae6371f6a..22546b8e50 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget { _QuickLink( title: context.t.recently_added, icon: Icons.upload_outlined, - isTop: true, onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), ), _QuickLink( From 564cda50880c97af12b0175d80167d1720ef8ce4 Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Wed, 22 Jul 2026 20:53:21 +0300 Subject: [PATCH 3/5] chore(mobile): Adds Belarusian language option in settings on mobile (#29939) chore(mobile): add missing Belarusian (be) language option in settings --- mobile/lib/constants/locales.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart index 3082a1a0dd..ed87deab8a 100644 --- a/mobile/lib/constants/locales.dart +++ b/mobile/lib/constants/locales.dart @@ -6,6 +6,7 @@ const Map locales = { // Additional locales 'Arabic (ar)': Locale('ar'), 'Basque (eu)': Locale('eu'), + 'Belarusian (be)': Locale('be'), 'Bosnian (bl)': Locale('bn'), 'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'), 'Bulgarian (bg)': Locale('bg'), From 1f81eac8ab3989a5b46721c498d1731a871ee0ed Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:18:28 +0530 Subject: [PATCH 4/5] refactor: toast repository (#29386) refactor: feedback repository Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../infrastructure/toast.provider.dart | 4 +++ mobile/lib/repositories/toast.repository.dart | 26 +++++++++++++++++++ mobile/packages/ui/lib/src/snackbar.dart | 22 +++++++++++----- 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/providers/infrastructure/toast.provider.dart create mode 100644 mobile/lib/repositories/toast.repository.dart diff --git a/mobile/lib/providers/infrastructure/toast.provider.dart b/mobile/lib/providers/infrastructure/toast.provider.dart new file mode 100644 index 0000000000..27d1cf9e6b --- /dev/null +++ b/mobile/lib/providers/infrastructure/toast.provider.dart @@ -0,0 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/toast.repository.dart'; + +final toastRepositoryProvider = Provider((ref) => const .new()); diff --git a/mobile/lib/repositories/toast.repository.dart b/mobile/lib/repositories/toast.repository.dart new file mode 100644 index 0000000000..0cca50fdec --- /dev/null +++ b/mobile/lib/repositories/toast.repository.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:immich_ui/immich_ui.dart'; + +class ToastOption { + final Duration? timeout; + final FutureOr Function()? onUndo; + + const ToastOption({this.timeout, this.onUndo}); +} + +class ToastRepository { + const ToastRepository(); + + FutureOr success(String message, {ToastOption? toast}) { + snackbar.success(message, duration: toast?.timeout); + } + + FutureOr info(String message, {ToastOption? toast}) { + snackbar.info(message, duration: toast?.timeout); + } + + FutureOr error(String message, {ToastOption? toast}) { + snackbar.error(message, duration: toast?.timeout); + } +} diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart index a44be8d513..1ede1124a8 100644 --- a/mobile/packages/ui/lib/src/snackbar.dart +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey(); class SnackbarManager { const SnackbarManager(); - ScaffoldFeatureController? show(String message, SnackbarType type) { + ScaffoldFeatureController? show( + String message, + SnackbarType type, { + Duration? duration, + }) { final messenger = scaffoldMessengerKey.currentState; final context = scaffoldMessengerKey.currentContext; if (messenger == null || context == null) { return null; } + duration ??= const .new(seconds: 4); messenger.hideCurrentSnackBar(); - return messenger.showSnackBar(_build(context, message, type)); + return messenger.showSnackBar(_build(context, message, type, duration)); } - SnackBar _build(BuildContext context, String message, SnackbarType type) { + SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) { final theme = Theme.of(context); final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); final (IconData icon, Color background, Color foreground) = switch (type) { @@ -29,7 +34,7 @@ class SnackbarManager { return SnackBar( behavior: .floating, backgroundColor: background, - duration: const .new(seconds: 4), + duration: duration, shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), content: Row( children: [ @@ -48,11 +53,14 @@ class SnackbarManager { ); } - ScaffoldFeatureController? info(String message) => show(message, .info); + ScaffoldFeatureController? info(String message, {Duration? duration}) => + show(message, .info, duration: duration); - ScaffoldFeatureController? success(String message) => show(message, .success); + ScaffoldFeatureController? success(String message, {Duration? duration}) => + show(message, .success, duration: duration); - ScaffoldFeatureController? error(String message) => show(message, .error); + ScaffoldFeatureController? error(String message, {Duration? duration}) => + show(message, .error, duration: duration); } const snackbar = SnackbarManager(); From 9403e71d23202c5d538148c2336b9fc127b43ef5 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:21:06 +0530 Subject: [PATCH 5/5] refactor: add asset update method (#29384) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/constants/enums.dart | 2 -- .../repositories/remote_asset.repository.dart | 17 ++++++++++ .../repositories/asset_api.repository.dart | 32 ++++++++++++++----- mobile/lib/services/action.service.dart | 8 ++--- mobile/lib/utils/option.dart | 13 ++++++++ 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 72479416a8..d59c48c045 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -9,8 +9,6 @@ enum SortOrder { enum TextSearchType { context, filename, description, ocr } -enum AssetVisibilityEnum { timeline, hidden, archive, locked } - enum ActionSource { timeline, viewer } enum ShareAssetType { original, preview } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index b2cecaca35..db89bfc1fc 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { @@ -292,4 +293,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ..orderBy([(row) => OrderingTerm.asc(row.sequence)]); return query.map((row) => row.toDto()!).get(); } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + }) { + final companion = RemoteAssetEntityCompanion( + visibility: visibility.toDriftValue(), + isFavorite: isFavorite.toDriftValue(), + ); + return _db.batch((batch) { + for (final remoteId in remoteIds) { + batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId)); + } + }); + } } diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 40233e90c4..f6ab726de6 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; -import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository { return response?.count ?? 0; } - Future updateVisibility(List ids, AssetVisibilityEnum visibility) async { + Future updateVisibility(List ids, AssetVisibility visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility)))); } @@ -77,11 +79,11 @@ class AssetApiRepository extends ApiRepository { return _api.downloadAssetWithHttpInfo(id, edited: edited); } - _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { - AssetVisibilityEnum.timeline => AssetVisibility.timeline, - AssetVisibilityEnum.hidden => AssetVisibility.hidden, - AssetVisibilityEnum.locked => AssetVisibility.locked, - AssetVisibilityEnum.archive => AssetVisibility.archive, + api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) { + AssetVisibility.timeline => api.AssetVisibility.timeline, + AssetVisibility.hidden => api.AssetVisibility.hidden, + AssetVisibility.locked => api.AssetVisibility.locked, + AssetVisibility.archive => api.AssetVisibility.archive, }; Future getAssetMIMEType(String assetId) async { @@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository { Future removeEdits(String assetId) async { return _api.removeAssetEdits(assetId); } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + }) { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: remoteIds, + isFavorite: isFavorite.toOptional(), + visibility: visibility.map(_mapVisibility).toOptional(), + ), + ); + } } extension on StackResponseDto { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 8e01777c5d..19782c8512 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -79,17 +79,17 @@ class ActionService { } Future archive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive); + await _assetApiRepository.updateVisibility(remoteIds, .archive); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive); } Future unArchive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, .timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } Future moveToLockFolder(List remoteIds, List localIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked); + await _assetApiRepository.updateVisibility(remoteIds, .locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); // Ask user if they want to delete local copies @@ -99,7 +99,7 @@ class ActionService { } Future removeFromLockFolder(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, .timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart index d98dad1995..e88ae59c1f 100644 --- a/mobile/lib/utils/option.dart +++ b/mobile/lib/utils/option.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart'; import 'package:openapi/api.dart' show Optional; sealed class Option { @@ -21,6 +22,11 @@ sealed class Option { None() => null, }; + Option map(U Function(T value) f) => switch (this) { + Some(:final value) => Some(f(value)), + None() => None(), + }; + U fold(U Function(T value) onSome, U Function() onNone) => switch (this) { Some(:final value) => onSome(value), None() => onNone(), @@ -65,3 +71,10 @@ extension OptionToOptional on Option { Some(:final value) => Optional.present(value), }; } + +extension OptionToDriftValue on Option { + Value toDriftValue() => switch (this) { + Some(:final value) => Value(value), + None() => const Value.absent(), + }; +}