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 5062d24a6b..0000000000 Binary files a/docs/docs/administration/img/reset-admin-password.webp and /dev/null differ 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/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/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'), 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/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( 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/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/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/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index d4759ec9d3..b00e2456a2 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); // Locked assets stay on the server, so permanently delete the local copies instead of trashing them @@ -100,7 +100,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(), + }; +} 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(); diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 429d64b3ec..447f016a8a 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.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/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; @@ -197,7 +196,7 @@ void main() { test('permanently deletes local copies without trashing, even when Android trash handling is on', () async { await Store.put(StoreKey.manageLocalMediaAndroid, true); - when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {}); + when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => localIds); when(() => localAssetRepository.delete(localIds)).thenAnswer((_) async {}); @@ -205,7 +204,7 @@ void main() { final result = await sut.moveToLockFolder(remoteIds, localIds); expect(result, localIds.length); - verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).called(1); + verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1); verify(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1); verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1); verify(() => localAssetRepository.delete(localIds)).called(1); @@ -213,18 +212,18 @@ void main() { }); test('locks remote assets without touching local media when there are no local copies', () async { - when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {}); + when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); final result = await sut.moveToLockFolder(remoteIds, const []); expect(result, 0); - verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).called(1); + verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1); verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash'))); }); test('returns zero when local deletion is cancelled', () async { - when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {}); + when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => []); @@ -237,7 +236,7 @@ void main() { test('returns the number of local copies deleted from a partial result', () async { const deletedIds = ['l1']; - when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked)).thenAnswer((_) async {}); + when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {}); when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => deletedIds); when(() => localAssetRepository.delete(deletedIds)).thenAnswer((_) async {}); 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 }; }