Compare commits

...

5 Commits

Author SHA1 Message Date
Adam Gastineau
5302d6ea0e fix(mobile): allow URL validation to pass when scheme is not provided 2026-07-22 12:39:23 -07:00
Pavel Miniutka
564cda5088 chore(mobile): Adds Belarusian language option in settings on mobile (#29939)
chore(mobile): add missing Belarusian (be) language option in settings
2026-07-22 17:53:21 +00:00
Alex
a7f1d495c6 fix: wrong corner radius of recently added link (#30140) 2026-07-22 16:44:14 +00:00
Jason Rasmussen
b9f6c4aaf2 feat: password invalidate sessions (#30125) 2026-07-22 12:09:05 -04:00
shenlong
23778551f7 fix: locked view and asset view provider (#30136)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-22 09:51:33 -05:00
14 changed files with 114 additions and 61 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ const Map<String, Locale> 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'),

View File

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

View File

@@ -1,8 +1,19 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
void onChange() => ref.invalidateSelf();
router.addListener(onChange);
ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

View File

@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance
@@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver {
AppNavigationObserver({required this.ref});
@override
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
}
@override
void didPush(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(route, previousRoute);
Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
if (route.settings.name == AssetViewerRoute.name) {
ref.read(isAssetViewerOpenProvider.notifier).state = true;
}
});
}
@override
void didPop(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(previousRoute ?? route, null);
if (route.settings.name == AssetViewerRoute.name) {
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
}
}
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =
route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name;
final isFromDetailViewToInfoPanelView =
route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView;
if (route.settings.name == DriftLockedFolderRoute.name ||
isFromLockedViewToDetailView ||
isFromDetailViewToInfoPanelView) {
Future(() => ref.read(inLockedViewProvider.notifier).state = true);
} else {
Future(() => ref.read(inLockedViewProvider.notifier).state = false);
}
}
}

View File

@@ -10,6 +10,24 @@ String sanitizeUrl(String url) {
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
}
/// Validates a user-entered server URL
bool isValidServerUrl(String? url) {
if (url == null || url.isEmpty) {
return true;
}
if (!url.contains('://')) {
// Prepend conforming scheme for URL validation
// Uri.tryParse will not parse out host without a scheme provided, even though we assume http(s) when connecting
url = 'http://$url';
}
final parsedUrl = Uri.tryParse(url);
return parsedUrl != null &&
(parsedUrl.scheme.isEmpty || parsedUrl.scheme.startsWith(RegExp(r'https?'))) &&
parsedUrl.host.isNotEmpty;
}
String? getServerUrl() {
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;

View File

@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) {
return null;
}
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateUrl(String? url) => isValidServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
String? _validateEmail(String? email) {
if (email == null || email == '') {

View File

@@ -77,6 +77,40 @@ void main() {
});
});
group('isValidServerUrl', () {
test('should treat null as valid', () {
expect(isValidServerUrl(null), isTrue);
});
test('should treat empty string as valid', () {
expect(isValidServerUrl(''), isTrue);
});
test('should accept a bare host', () {
expect(isValidServerUrl('demo.immich.app'), isTrue);
});
test('should accept a bare host with a port', () {
expect(isValidServerUrl('192.168.1.1:2283'), isTrue);
});
test('should accept an http URL', () {
expect(isValidServerUrl('http://demo.immich.app'), isTrue);
});
test('should accept an https URL', () {
expect(isValidServerUrl('https://demo.immich.app:2283/api'), isTrue);
});
test('should reject a non-http scheme', () {
expect(isValidServerUrl('ftp://demo.immich.app'), isFalse);
});
test('should reject scheme only input', () {
expect(isValidServerUrl('https://'), isFalse);
});
});
group('punycodeDecodeUrl', () {
test('should return null for null input', () {
expect(punycodeDecodeUrl(null), isNull);

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -58,18 +58,24 @@ export class CliService extends BaseService {
return users.map((user) => mapUserAdmin(user));
}
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
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 };
}