From bd838a71d14828bb0ece561503dd0236a13ad6f2 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Jan 2023 16:32:58 -0500 Subject: [PATCH] feat(web,server): disable password login (#1223) * feat(web,server): disable password login * chore: unit tests * chore: fix import * chore: linting * feat(cli): server command for enable/disable password login * chore: update docs * feat(web): confirm dialogue * chore: linting * chore: linting * chore: linting * chore: linting * chore: linting * chore: fix web test * chore: server unit tests --- .../features/img/password-login-settings.png | Bin 0 -> 18917 bytes .../features/img/user-management-update.png | Bin 0 -> 22216 bytes docs/docs/features/oauth.md | 25 ++-- docs/docs/features/password-login.md | 32 +++++ docs/docs/features/server-commands.md | 34 +++-- docs/docs/features/user-management.mdx | 6 + mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 1 + mobile/openapi/doc/OAuthConfigResponseDto.md | 8 +- mobile/openapi/doc/SharedLinkResponseDto.md | 2 +- mobile/openapi/doc/SystemConfigDto.md | 1 + mobile/openapi/doc/SystemConfigOAuthDto.md | 1 + .../doc/SystemConfigPasswordLoginDto.md | 15 +++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../lib/model/o_auth_config_response_dto.dart | 31 ++++- .../lib/model/shared_link_response_dto.dart | 6 +- .../openapi/lib/model/system_config_dto.dart | 10 +- .../lib/model/system_config_o_auth_dto.dart | 10 +- .../system_config_password_login_dto.dart | 111 +++++++++++++++ .../test/o_auth_config_response_dto_test.dart | 10 ++ .../test/shared_link_response_dto_test.dart | 2 +- .../openapi/test/system_config_dto_test.dart | 5 + .../test/system_config_o_auth_dto_test.dart | 5 + ...system_config_password_login_dto_test.dart | 27 ++++ server/apps/cli/src/app.module.ts | 12 +- .../apps/cli/src/commands/password-login.ts | 39 ++++++ .../src/api-v1/album/album.service.spec.ts | 2 + .../src/api-v1/asset/asset.service.spec.ts | 3 +- .../immich/src/api-v1/auth/auth.module.ts | 3 +- .../src/api-v1/auth/auth.service.spec.ts | 46 ++++++- .../immich/src/api-v1/auth/auth.service.ts | 11 +- .../src/api-v1/oauth/oauth.service.spec.ts | 16 ++- .../immich/src/api-v1/oauth/oauth.service.ts | 15 ++- .../response-dto/oauth-config-response.dto.ts | 9 +- .../dto/system-config-oauth.dto.ts | 3 + .../dto/system-config-password-login.dto.ts | 6 + .../system-config/dto/system-config.dto.ts | 4 + server/apps/immich/src/app.controller.ts | 16 ++- server/apps/immich/src/app.module.ts | 4 +- .../immich-jwt/strategies/api-key.strategy.ts | 2 +- .../immich-jwt/strategies/jwt.strategy.ts | 4 +- .../strategies/public-share.strategy.ts | 4 +- server/immich-openapi-specs.json | 37 ++++- .../src/entities/system-config.entity.ts | 8 +- .../src/immich-config.service.ts | 4 + web/package.json | 2 +- web/src/api/open-api/api.ts | 41 +++++- web/src/api/utils.ts | 9 ++ .../settings/confirm-disable-login.svelte | 25 ++++ .../settings/oauth/oauth-settings.svelte | 50 +++++-- .../password-login-settings.svelte | 119 ++++++++++++++++ .../album-page/__tests__/album-card.spec.ts | 1 - .../components/album-page/album-viewer.svelte | 2 +- .../asset-viewer/album-list-item.svelte | 2 +- .../asset-viewer/video-viewer.svelte | 2 - .../lib/components/forms/login-form.svelte | 127 ++++++++++-------- ...ialogue.svelte => confirm-dialogue.svelte} | 10 +- .../create-shared-link-modal.svelte | 4 - .../navigation-bar/navigation-bar.svelte | 4 +- .../shared-components/portal/portal.svelte | 3 +- .../sharedlinks-page/shared-link-card.svelte | 2 +- .../user-settings-page/oauth-settings.svelte | 2 +- .../user-api-key-list.svelte | 4 +- web/src/lib/utils/asset-utils.ts | 5 +- .../routes/admin/system-settings/+page.svelte | 18 ++- 66 files changed, 861 insertions(+), 167 deletions(-) create mode 100644 docs/docs/features/img/password-login-settings.png create mode 100644 docs/docs/features/img/user-management-update.png create mode 100644 docs/docs/features/password-login.md create mode 100644 mobile/openapi/doc/SystemConfigPasswordLoginDto.md create mode 100644 mobile/openapi/lib/model/system_config_password_login_dto.dart create mode 100644 mobile/openapi/test/system_config_password_login_dto_test.dart create mode 100644 server/apps/cli/src/commands/password-login.ts create mode 100644 server/apps/immich/src/api-v1/system-config/dto/system-config-password-login.dto.ts create mode 100644 web/src/lib/components/admin-page/settings/confirm-disable-login.svelte create mode 100644 web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte rename web/src/lib/components/shared-components/{delete-confirm-dialogue.svelte => confirm-dialogue.svelte} (87%) diff --git a/docs/docs/features/img/password-login-settings.png b/docs/docs/features/img/password-login-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..2c87081db50acd2387ccd0297f8b23c23e54c32e GIT binary patch literal 18917 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≥z^ImDtQXHP#z8rHs>FGJdtiCS+h_M37JMXe+K@ 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 Settings). -| Setting | Type | Default | Description | -| ---------------------------- | ------- | -------------------- | ------------------------------------------------------------------------- | -| Enabled | boolean | false | Enable/disable OAuth | -| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) | -| Client ID | string | (required) | Required. Client ID (from previous step) | -| Client secret | string | (required) | Required. Client Secret (previous step) | -| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) | -| Button text | string | Login with OAuth | Text for the OAuth button on the web | -| Auto register | boolean | true | When true, will automatically register a user the first time they sign in | -| Mobile Redirect URI Override | URL | (empty) | Http(s) alternative mobile redirect URI | +| Setting | Type | Default | Description | +| ---------------------------------------------------- | ------- | -------------------- | ----------------------------------------------------------------------------------- | +| Enabled | boolean | false | Enable/disable OAuth | +| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) | +| Client ID | string | (required) | Required. Client ID (from previous step) | +| Client Secret | string | (required) | Required. Client Secret (previous step) | +| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) | +| Button Text | string | Login with OAuth | Text for the OAuth button on the web | +| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in | +| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process | +| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI | :::info The Issuer URL should look something like the following, and return a valid json document. @@ -79,6 +80,10 @@ The Issuer URL should look something like the following, and return a valid json The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery. ::: +## Auto Launch + +When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`. + ## Mobile Redirect URI The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: diff --git a/docs/docs/features/password-login.md b/docs/docs/features/password-login.md new file mode 100644 index 000000000..76ab057e8 --- /dev/null +++ b/docs/docs/features/password-login.md @@ -0,0 +1,32 @@ +# Password Login + +An overview of password login and related settings for Immich. + +## Enable/Disable + +Immich supports password login, which is enabled by default. The preferred way to disable it is via the [Administration Page](#administration-page), although it can also be changed via a [Server Command](#server-command) as well. + +### Administration Page + +To toggle the password login setting via the web, navigate to the "Administration", expand "Password Authentication", toggle the "Enabled" switch, and press "Save". + +![Password Login Settings](./img/password-login-settings.png) + +### Server Command + +There are two [Server Commands](/docs/features/server-commands.md) for password login: + +1. `enable-password-login` +2. `disable-password-login` + +See [Server Commands](/docs/features/server-commands.md) for more details about how to run them. + +## Password Reset + +### Admin + +To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/features/server-commands.md). + +### User + +Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/features/user-management.mdx#password-reset) for more information about how to do this. diff --git a/docs/docs/features/server-commands.md b/docs/docs/features/server-commands.md index 0a41099e3..d8c017cad 100644 --- a/docs/docs/features/server-commands.md +++ b/docs/docs/features/server-commands.md @@ -1,21 +1,39 @@ # Server Commands -The `immich-server` docker image comes preinstalled with an administrative CLI that supports the following commands: +The `immich-server` docker image comes preinstalled with an administrative CLI (`immich`) that supports the following commands: -| Command | Description | -| ----------------------------- | ------------------------------------- | -| `immich help` | Display help | -| `immich reset-admin-password` | Reset the password for the admin user | +| Command | Description | +| ------------------------ | ------------------------------------- | +| `help` | Display help | +| `reset-admin-password` | Reset the password for the admin user | +| `disable-password-login` | Disable password login | +| `enable-password-login` | Enable password login | ## How to run a command -To run a command, connect to the container and then execute it. For example: +To run a command, connect to the container and then execute it by running `immich `. -```bash -docker exec -it immich-server_1 sh +## Examples + +```bash title="Reset Admin Password" +docker exec -it immich_server sh /usr/src/app$ immich reset-admin-password ? Please choose a new password (optional) immich-is-awesome-unlike-this-password New password: immich-is-awesome-unlike-this-password ``` + +```bash title="Disable Password Login" +docker exec -it immich_server sh + +/usr/src/app$ immich disable-password-login +Password login has been disabled. +``` + +```bash title="Enable Password Login" +docker exec -it immich_server sh + +/usr/src/app$ immich enable-password-login +Password login has been enabled. +``` diff --git a/docs/docs/features/user-management.mdx b/docs/docs/features/user-management.mdx index a3d977c06..10715c5a9 100644 --- a/docs/docs/features/user-management.mdx +++ b/docs/docs/features/user-management.mdx @@ -16,3 +16,9 @@ Immich supports multiple users, each with their own library. ## Delete a User If you need to remove a user from Immich, head to "Administration", where users can be scheduled for deletion. The user account will immediately become disabled and their library and all associated data will be removed after 7 days. + +## Password Reset + +To reset a user's password, click the pencil icon to edit a user, then click "Reset Password". The user's password will be reset to "password" and they have to change it next time the sign in. + +![Reset Password](./img/user-management-update.png) diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 76099cd88..d68e7e9b3 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -76,6 +76,7 @@ doc/SystemConfigApi.md doc/SystemConfigDto.md doc/SystemConfigFFmpegDto.md doc/SystemConfigOAuthDto.md +doc/SystemConfigPasswordLoginDto.md doc/SystemConfigStorageTemplateDto.md doc/SystemConfigTemplateStorageOptionDto.md doc/TagApi.md @@ -178,6 +179,7 @@ lib/model/smart_info_response_dto.dart lib/model/system_config_dto.dart lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_o_auth_dto.dart +lib/model/system_config_password_login_dto.dart lib/model/system_config_storage_template_dto.dart lib/model/system_config_template_storage_option_dto.dart lib/model/tag_response_dto.dart @@ -267,6 +269,7 @@ test/system_config_api_test.dart test/system_config_dto_test.dart test/system_config_f_fmpeg_dto_test.dart test/system_config_o_auth_dto_test.dart +test/system_config_password_login_dto_test.dart test/system_config_storage_template_dto_test.dart test/system_config_template_storage_option_dto_test.dart test/tag_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 786043e17..5ff0099b0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -206,6 +206,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md) + - [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md) - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md) - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md) - [TagResponseDto](doc//TagResponseDto.md) diff --git a/mobile/openapi/doc/OAuthConfigResponseDto.md b/mobile/openapi/doc/OAuthConfigResponseDto.md index 8d6c3a41e..ae1d42c12 100644 --- a/mobile/openapi/doc/OAuthConfigResponseDto.md +++ b/mobile/openapi/doc/OAuthConfigResponseDto.md @@ -8,9 +8,11 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**enabled** | **bool** | | [readonly] -**url** | **String** | | [optional] [readonly] -**buttonText** | **String** | | [optional] [readonly] +**enabled** | **bool** | | +**passwordLoginEnabled** | **bool** | | +**url** | **String** | | [optional] +**buttonText** | **String** | | [optional] +**autoLaunch** | **bool** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SharedLinkResponseDto.md b/mobile/openapi/doc/SharedLinkResponseDto.md index b27cc6dbc..c11a1a489 100644 --- a/mobile/openapi/doc/SharedLinkResponseDto.md +++ b/mobile/openapi/doc/SharedLinkResponseDto.md @@ -15,7 +15,7 @@ Name | Type | Description | Notes **key** | **String** | | **createdAt** | **String** | | **expiresAt** | **String** | | -**assets** | **List** | | [default to const []] +**assets** | [**List**](AssetResponseDto.md) | | [default to const []] **album** | [**AlbumResponseDto**](AlbumResponseDto.md) | | [optional] **allowUpload** | **bool** | | diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index 19209682f..8ad2bfb9a 100644 --- a/mobile/openapi/doc/SystemConfigDto.md +++ b/mobile/openapi/doc/SystemConfigDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | | **oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | | +**passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | | **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigOAuthDto.md b/mobile/openapi/doc/SystemConfigOAuthDto.md index dfdaa6712..745b13b79 100644 --- a/mobile/openapi/doc/SystemConfigOAuthDto.md +++ b/mobile/openapi/doc/SystemConfigOAuthDto.md @@ -15,6 +15,7 @@ Name | Type | Description | Notes **scope** | **String** | | **buttonText** | **String** | | **autoRegister** | **bool** | | +**autoLaunch** | **bool** | | **mobileOverrideEnabled** | **bool** | | **mobileRedirectUri** | **String** | | diff --git a/mobile/openapi/doc/SystemConfigPasswordLoginDto.md b/mobile/openapi/doc/SystemConfigPasswordLoginDto.md new file mode 100644 index 000000000..682a3c644 --- /dev/null +++ b/mobile/openapi/doc/SystemConfigPasswordLoginDto.md @@ -0,0 +1,15 @@ +# openapi.model.SystemConfigPasswordLoginDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**enabled** | **bool** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e2ea9592a..595ca69b5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -103,6 +103,7 @@ part 'model/smart_info_response_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_o_auth_dto.dart'; +part 'model/system_config_password_login_dto.dart'; part 'model/system_config_storage_template_dto.dart'; part 'model/system_config_template_storage_option_dto.dart'; part 'model/tag_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1e2ef461b..3a916af67 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -318,6 +318,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigOAuthDto': return SystemConfigOAuthDto.fromJson(value); + case 'SystemConfigPasswordLoginDto': + return SystemConfigPasswordLoginDto.fromJson(value); case 'SystemConfigStorageTemplateDto': return SystemConfigStorageTemplateDto.fromJson(value); case 'SystemConfigTemplateStorageOptionDto': diff --git a/mobile/openapi/lib/model/o_auth_config_response_dto.dart b/mobile/openapi/lib/model/o_auth_config_response_dto.dart index 29cdda644..db6c9c9e9 100644 --- a/mobile/openapi/lib/model/o_auth_config_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_response_dto.dart @@ -14,12 +14,16 @@ class OAuthConfigResponseDto { /// Returns a new [OAuthConfigResponseDto] instance. OAuthConfigResponseDto({ required this.enabled, + required this.passwordLoginEnabled, this.url, this.buttonText, + this.autoLaunch, }); bool enabled; + bool passwordLoginEnabled; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -36,25 +40,38 @@ class OAuthConfigResponseDto { /// String? buttonText; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? autoLaunch; + @override bool operator ==(Object other) => identical(this, other) || other is OAuthConfigResponseDto && other.enabled == enabled && + other.passwordLoginEnabled == passwordLoginEnabled && other.url == url && - other.buttonText == buttonText; + other.buttonText == buttonText && + other.autoLaunch == autoLaunch; @override int get hashCode => // ignore: unnecessary_parenthesis (enabled.hashCode) + + (passwordLoginEnabled.hashCode) + (url == null ? 0 : url!.hashCode) + - (buttonText == null ? 0 : buttonText!.hashCode); + (buttonText == null ? 0 : buttonText!.hashCode) + + (autoLaunch == null ? 0 : autoLaunch!.hashCode); @override - String toString() => 'OAuthConfigResponseDto[enabled=$enabled, url=$url, buttonText=$buttonText]'; + String toString() => 'OAuthConfigResponseDto[enabled=$enabled, passwordLoginEnabled=$passwordLoginEnabled, url=$url, buttonText=$buttonText, autoLaunch=$autoLaunch]'; Map toJson() { final _json = {}; _json[r'enabled'] = enabled; + _json[r'passwordLoginEnabled'] = passwordLoginEnabled; if (url != null) { _json[r'url'] = url; } else { @@ -65,6 +82,11 @@ class OAuthConfigResponseDto { } else { _json[r'buttonText'] = null; } + if (autoLaunch != null) { + _json[r'autoLaunch'] = autoLaunch; + } else { + _json[r'autoLaunch'] = null; + } return _json; } @@ -88,8 +110,10 @@ class OAuthConfigResponseDto { return OAuthConfigResponseDto( enabled: mapValueOfType(json, r'enabled')!, + passwordLoginEnabled: mapValueOfType(json, r'passwordLoginEnabled')!, url: mapValueOfType(json, r'url'), buttonText: mapValueOfType(json, r'buttonText'), + autoLaunch: mapValueOfType(json, r'autoLaunch'), ); } return null; @@ -140,6 +164,7 @@ class OAuthConfigResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'enabled', + 'passwordLoginEnabled', }; } diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 3c51d90d2..aad8972d2 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -45,7 +45,7 @@ class SharedLinkResponseDto { String? expiresAt; - List assets; + List assets; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -140,9 +140,7 @@ class SharedLinkResponseDto { key: mapValueOfType(json, r'key')!, createdAt: mapValueOfType(json, r'createdAt')!, expiresAt: mapValueOfType(json, r'expiresAt'), - assets: json[r'assets'] is List - ? (json[r'assets'] as List).cast() - : const [], + assets: AssetResponseDto.listFromJson(json[r'assets'])!, album: AlbumResponseDto.fromJson(json[r'album']), allowUpload: mapValueOfType(json, r'allowUpload')!, ); diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 22701819a..a4e976051 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -15,6 +15,7 @@ class SystemConfigDto { SystemConfigDto({ required this.ffmpeg, required this.oauth, + required this.passwordLogin, required this.storageTemplate, }); @@ -22,12 +23,15 @@ class SystemConfigDto { SystemConfigOAuthDto oauth; + SystemConfigPasswordLoginDto passwordLogin; + SystemConfigStorageTemplateDto storageTemplate; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && other.ffmpeg == ffmpeg && other.oauth == oauth && + other.passwordLogin == passwordLogin && other.storageTemplate == storageTemplate; @override @@ -35,15 +39,17 @@ class SystemConfigDto { // ignore: unnecessary_parenthesis (ffmpeg.hashCode) + (oauth.hashCode) + + (passwordLogin.hashCode) + (storageTemplate.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, oauth=$oauth, storageTemplate=$storageTemplate]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, oauth=$oauth, passwordLogin=$passwordLogin, storageTemplate=$storageTemplate]'; Map toJson() { final _json = {}; _json[r'ffmpeg'] = ffmpeg; _json[r'oauth'] = oauth; + _json[r'passwordLogin'] = passwordLogin; _json[r'storageTemplate'] = storageTemplate; return _json; } @@ -69,6 +75,7 @@ class SystemConfigDto { return SystemConfigDto( ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!, oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!, + passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!, storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!, ); } @@ -121,6 +128,7 @@ class SystemConfigDto { static const requiredKeys = { 'ffmpeg', 'oauth', + 'passwordLogin', 'storageTemplate', }; } diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index d291b501d..d0fb195cc 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -20,6 +20,7 @@ class SystemConfigOAuthDto { required this.scope, required this.buttonText, required this.autoRegister, + required this.autoLaunch, required this.mobileOverrideEnabled, required this.mobileRedirectUri, }); @@ -38,6 +39,8 @@ class SystemConfigOAuthDto { bool autoRegister; + bool autoLaunch; + bool mobileOverrideEnabled; String mobileRedirectUri; @@ -51,6 +54,7 @@ class SystemConfigOAuthDto { other.scope == scope && other.buttonText == buttonText && other.autoRegister == autoRegister && + other.autoLaunch == autoLaunch && other.mobileOverrideEnabled == mobileOverrideEnabled && other.mobileRedirectUri == mobileRedirectUri; @@ -64,11 +68,12 @@ class SystemConfigOAuthDto { (scope.hashCode) + (buttonText.hashCode) + (autoRegister.hashCode) + + (autoLaunch.hashCode) + (mobileOverrideEnabled.hashCode) + (mobileRedirectUri.hashCode); @override - String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, buttonText=$buttonText, autoRegister=$autoRegister, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]'; + String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, buttonText=$buttonText, autoRegister=$autoRegister, autoLaunch=$autoLaunch, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]'; Map toJson() { final _json = {}; @@ -79,6 +84,7 @@ class SystemConfigOAuthDto { _json[r'scope'] = scope; _json[r'buttonText'] = buttonText; _json[r'autoRegister'] = autoRegister; + _json[r'autoLaunch'] = autoLaunch; _json[r'mobileOverrideEnabled'] = mobileOverrideEnabled; _json[r'mobileRedirectUri'] = mobileRedirectUri; return _json; @@ -110,6 +116,7 @@ class SystemConfigOAuthDto { scope: mapValueOfType(json, r'scope')!, buttonText: mapValueOfType(json, r'buttonText')!, autoRegister: mapValueOfType(json, r'autoRegister')!, + autoLaunch: mapValueOfType(json, r'autoLaunch')!, mobileOverrideEnabled: mapValueOfType(json, r'mobileOverrideEnabled')!, mobileRedirectUri: mapValueOfType(json, r'mobileRedirectUri')!, ); @@ -168,6 +175,7 @@ class SystemConfigOAuthDto { 'scope', 'buttonText', 'autoRegister', + 'autoLaunch', 'mobileOverrideEnabled', 'mobileRedirectUri', }; diff --git a/mobile/openapi/lib/model/system_config_password_login_dto.dart b/mobile/openapi/lib/model/system_config_password_login_dto.dart new file mode 100644 index 000000000..f5562800a --- /dev/null +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -0,0 +1,111 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigPasswordLoginDto { + /// Returns a new [SystemConfigPasswordLoginDto] instance. + SystemConfigPasswordLoginDto({ + required this.enabled, + }); + + bool enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigPasswordLoginDto && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode); + + @override + String toString() => 'SystemConfigPasswordLoginDto[enabled=$enabled]'; + + Map toJson() { + final _json = {}; + _json[r'enabled'] = enabled; + return _json; + } + + /// Returns a new [SystemConfigPasswordLoginDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigPasswordLoginDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SystemConfigPasswordLoginDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SystemConfigPasswordLoginDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SystemConfigPasswordLoginDto( + enabled: mapValueOfType(json, r'enabled')!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigPasswordLoginDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigPasswordLoginDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigPasswordLoginDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigPasswordLoginDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + }; +} + diff --git a/mobile/openapi/test/o_auth_config_response_dto_test.dart b/mobile/openapi/test/o_auth_config_response_dto_test.dart index 1bb6cbc89..309324f88 100644 --- a/mobile/openapi/test/o_auth_config_response_dto_test.dart +++ b/mobile/openapi/test/o_auth_config_response_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // bool passwordLoginEnabled + test('to test the property `passwordLoginEnabled`', () async { + // TODO + }); + // String url test('to test the property `url`', () async { // TODO @@ -31,6 +36,11 @@ void main() { // TODO }); + // bool autoLaunch + test('to test the property `autoLaunch`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart index 46778bfa7..de19ef71b 100644 --- a/mobile/openapi/test/shared_link_response_dto_test.dart +++ b/mobile/openapi/test/shared_link_response_dto_test.dart @@ -51,7 +51,7 @@ void main() { // TODO }); - // List assets (default value: const []) + // List assets (default value: const []) test('to test the property `assets`', () async { // TODO }); diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index b4a3172ce..e44406f7e 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // SystemConfigPasswordLoginDto passwordLogin + test('to test the property `passwordLogin`', () async { + // TODO + }); + // SystemConfigStorageTemplateDto storageTemplate test('to test the property `storageTemplate`', () async { // TODO diff --git a/mobile/openapi/test/system_config_o_auth_dto_test.dart b/mobile/openapi/test/system_config_o_auth_dto_test.dart index 744f55dd0..ca5fadad4 100644 --- a/mobile/openapi/test/system_config_o_auth_dto_test.dart +++ b/mobile/openapi/test/system_config_o_auth_dto_test.dart @@ -51,6 +51,11 @@ void main() { // TODO }); + // bool autoLaunch + test('to test the property `autoLaunch`', () async { + // TODO + }); + // bool mobileOverrideEnabled test('to test the property `mobileOverrideEnabled`', () async { // TODO diff --git a/mobile/openapi/test/system_config_password_login_dto_test.dart b/mobile/openapi/test/system_config_password_login_dto_test.dart new file mode 100644 index 000000000..a8d87d154 --- /dev/null +++ b/mobile/openapi/test/system_config_password_login_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SystemConfigPasswordLoginDto +void main() { + // final instance = SystemConfigPasswordLoginDto(); + + group('test SystemConfigPasswordLoginDto', () { + // bool enabled + test('to test the property `enabled`', () async { + // TODO + }); + + + }); + +} diff --git a/server/apps/cli/src/app.module.ts b/server/apps/cli/src/app.module.ts index b529c7b13..b1b0de7ec 100644 --- a/server/apps/cli/src/app.module.ts +++ b/server/apps/cli/src/app.module.ts @@ -1,10 +1,16 @@ -import { DatabaseModule, UserEntity } from '@app/database'; +import { DatabaseModule, SystemConfigEntity, UserEntity } from '@app/database'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './commands/password-login'; import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command'; @Module({ - imports: [DatabaseModule, TypeOrmModule.forFeature([UserEntity])], - providers: [ResetAdminPasswordCommand, PromptPasswordQuestions], + imports: [DatabaseModule, TypeOrmModule.forFeature([UserEntity, SystemConfigEntity])], + providers: [ + ResetAdminPasswordCommand, + PromptPasswordQuestions, + EnablePasswordLoginCommand, + DisablePasswordLoginCommand, + ], }) export class AppModule {} diff --git a/server/apps/cli/src/commands/password-login.ts b/server/apps/cli/src/commands/password-login.ts new file mode 100644 index 000000000..a2a07a33e --- /dev/null +++ b/server/apps/cli/src/commands/password-login.ts @@ -0,0 +1,39 @@ +import { SystemConfigEntity, SystemConfigKey } from '@app/database'; +import { InjectRepository } from '@nestjs/typeorm'; +import axios from 'axios'; +import { Command, CommandRunner } from 'nest-commander'; +import { Repository } from 'typeorm'; + +@Command({ + name: 'enable-password-login', + description: 'Enable password login', +}) +export class EnablePasswordLoginCommand extends CommandRunner { + constructor( + @InjectRepository(SystemConfigEntity) private repository: Repository, // + ) { + super(); + } + + async run(): Promise { + await this.repository.delete({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED }); + await axios.post('http://localhost:3001/refresh-config'); + console.log('Password login has been enabled.'); + } +} + +@Command({ + name: 'disable-password-login', + description: 'Disable password login', +}) +export class DisablePasswordLoginCommand extends CommandRunner { + constructor(@InjectRepository(SystemConfigEntity) private repository: Repository) { + super(); + } + + async run(): Promise { + await this.repository.save({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }); + await axios.post('http://localhost:3001/refresh-config'); + console.log('Password login has been disabled.'); + } +} diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 61633d7c9..a14c6d2ba 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -136,6 +136,8 @@ describe('Album service', () => { getById: jest.fn(), getByKey: jest.fn(), save: jest.fn(), + hasAssetAccess: jest.fn(), + getByIdAndUserId: jest.fn(), }; downloadServiceMock = { diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index ce38fc84e..0778f8068 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -130,7 +130,6 @@ describe('AssetService', () => { getAssetWithNoSmartInfo: jest.fn(), getExistingAssets: jest.fn(), countByIdAndUser: jest.fn(), - getSharePermission: jest.fn(), }; downloadServiceMock = { @@ -144,6 +143,8 @@ describe('AssetService', () => { getByKey: jest.fn(), remove: jest.fn(), save: jest.fn(), + hasAssetAccess: jest.fn(), + getByIdAndUserId: jest.fn(), }; sui = new AssetService( diff --git a/server/apps/immich/src/api-v1/auth/auth.module.ts b/server/apps/immich/src/api-v1/auth/auth.module.ts index 4a06f0ae8..f1d93f067 100644 --- a/server/apps/immich/src/api-v1/auth/auth.module.ts +++ b/server/apps/immich/src/api-v1/auth/auth.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ImmichConfigModule } from '@app/immich-config'; import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; import { OAuthModule } from '../oauth/oauth.module'; import { UserModule } from '../user/user.module'; @@ -6,7 +7,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @Module({ - imports: [UserModule, ImmichJwtModule, OAuthModule], + imports: [UserModule, ImmichJwtModule, OAuthModule, ImmichConfigModule], controllers: [AuthController], providers: [AuthService], }) diff --git a/server/apps/immich/src/api-v1/auth/auth.service.spec.ts b/server/apps/immich/src/api-v1/auth/auth.service.spec.ts index b84420d1b..923417137 100644 --- a/server/apps/immich/src/api-v1/auth/auth.service.spec.ts +++ b/server/apps/immich/src/api-v1/auth/auth.service.spec.ts @@ -1,6 +1,8 @@ import { UserEntity } from '@app/database'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; +import { SystemConfig } from '@app/database/entities/system-config.entity'; +import { ImmichConfigService } from '@app/immich-config'; import { AuthType } from '../../constants/jwt.constant'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { OAuthService } from '../oauth/oauth.service'; @@ -16,6 +18,19 @@ const fixtures = { }, }; +const config = { + enabled: { + passwordLogin: { + enabled: true, + }, + } as SystemConfig, + disabled: { + passwordLogin: { + enabled: false, + }, + } as SystemConfig, +}; + const CLIENT_IP = '127.0.0.1'; jest.mock('bcrypt'); @@ -35,6 +50,7 @@ describe('AuthService', () => { let sut: AuthService; let userRepositoryMock: jest.Mocked; let immichJwtServiceMock: jest.Mocked; + let immichConfigServiceMock: jest.Mocked; let oauthServiceMock: jest.Mocked; let compare: jest.Mock; @@ -71,14 +87,40 @@ describe('AuthService', () => { getLogoutEndpoint: jest.fn(), } as unknown as jest.Mocked; - sut = new AuthService(oauthServiceMock, immichJwtServiceMock, userRepositoryMock); + immichConfigServiceMock = { + config$: { subscribe: jest.fn() }, + } as unknown as jest.Mocked; + + sut = new AuthService( + oauthServiceMock, + immichJwtServiceMock, + userRepositoryMock, + immichConfigServiceMock, + config.enabled, + ); }); it('should be defined', () => { expect(sut).toBeDefined(); }); + it('should subscribe to config changes', async () => { + expect(immichConfigServiceMock.config$.subscribe).toHaveBeenCalled(); + }); + describe('login', () => { + it('should throw an error if password login is disabled', async () => { + sut = new AuthService( + oauthServiceMock, + immichJwtServiceMock, + userRepositoryMock, + immichConfigServiceMock, + config.disabled, + ); + + await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(UnauthorizedException); + }); + it('should check the user exists', async () => { userRepositoryMock.getByEmail.mockResolvedValue(null); await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException); @@ -170,7 +212,7 @@ describe('AuthService', () => { it('should return the default redirect', async () => { await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({ successful: true, - redirectUri: '/auth/login', + redirectUri: '/auth/login?autoLaunch=0', }); expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled(); }); diff --git a/server/apps/immich/src/api-v1/auth/auth.service.ts b/server/apps/immich/src/api-v1/auth/auth.service.ts index 6a671decb..1721c7d31 100644 --- a/server/apps/immich/src/api-v1/auth/auth.service.ts +++ b/server/apps/immich/src/api-v1/auth/auth.service.ts @@ -20,6 +20,8 @@ import { LoginResponseDto } from './response-dto/login-response.dto'; import { LogoutResponseDto } from './response-dto/logout-response.dto'; import { OAuthService } from '../oauth/oauth.service'; import { UserCore } from '../user/user.core'; +import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config'; +import { SystemConfig } from '@app/database/entities/system-config.entity'; @Injectable() export class AuthService { @@ -30,11 +32,18 @@ export class AuthService { private oauthService: OAuthService, private immichJwtService: ImmichJwtService, @Inject(IUserRepository) userRepository: IUserRepository, + private configService: ImmichConfigService, + @Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig, ) { this.userCore = new UserCore(userRepository); + this.configService.config$.subscribe((config) => (this.config = config)); } public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise { + if (!this.config.passwordLogin.enabled) { + throw new UnauthorizedException('Password login has been disabled'); + } + let user = await this.userCore.getByEmail(loginCredential.email, true); if (user) { @@ -60,7 +69,7 @@ export class AuthService { } } - return { successful: true, redirectUri: '/auth/login' }; + return { successful: true, redirectUri: '/auth/login?autoLaunch=0' }; } public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) { diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts index 1e931a80a..645c49ef5 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts +++ b/server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts @@ -17,29 +17,37 @@ const config = { enabled: false, buttonText: 'OAuth', issuerUrl: 'http://issuer,', + autoLaunch: false, }, + passwordLogin: { enabled: true }, } as SystemConfig, enabled: { oauth: { enabled: true, autoRegister: true, buttonText: 'OAuth', + autoLaunch: false, }, + passwordLogin: { enabled: true }, } as SystemConfig, noAutoRegister: { oauth: { enabled: true, autoRegister: false, + autoLaunch: false, }, + passwordLogin: { enabled: true }, } as SystemConfig, override: { oauth: { enabled: true, autoRegister: true, + autoLaunch: false, buttonText: 'OAuth', mobileOverrideEnabled: true, mobileRedirectUri: 'http://mobile-redirect', }, + passwordLogin: { enabled: true }, } as SystemConfig, }; @@ -124,7 +132,6 @@ describe('OAuthService', () => { immichConfigServiceMock = { config$: { subscribe: jest.fn() }, - getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }), } as unknown as jest.Mocked; sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled); @@ -136,7 +143,10 @@ describe('OAuthService', () => { describe('generateConfig', () => { it('should work when oauth is not configured', async () => { - await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false }); + await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ + enabled: false, + passwordLoginEnabled: true, + }); }); it('should generate the config', async () => { @@ -145,6 +155,8 @@ describe('OAuthService', () => { enabled: true, buttonText: 'OAuth', url: 'http://authorization-url', + autoLaunch: false, + passwordLoginEnabled: true, }); }); }); diff --git a/server/apps/immich/src/api-v1/oauth/oauth.service.ts b/server/apps/immich/src/api-v1/oauth/oauth.service.ts index 8c62f8f62..6097dcd6f 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.ts +++ b/server/apps/immich/src/api-v1/oauth/oauth.service.ts @@ -39,19 +39,24 @@ export class OAuthService { } public async generateConfig(dto: OAuthConfigDto): Promise { - const { enabled, scope, buttonText } = this.config.oauth; - const redirectUri = this.normalize(dto.redirectUri); + const response = { + enabled: this.config.oauth.enabled, + passwordLoginEnabled: this.config.passwordLogin.enabled, + }; - if (!enabled) { - return { enabled: false }; + if (!response.enabled) { + return response; } + const { scope, buttonText, autoLaunch } = this.config.oauth; + const redirectUri = this.normalize(dto.redirectUri); const url = (await this.getClient()).authorizationUrl({ redirect_uri: redirectUri, scope, state: generators.state(), }); - return { enabled: true, buttonText, url }; + + return { ...response, buttonText, url, autoLaunch }; } public async login(dto: OAuthCallbackDto): Promise { diff --git a/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts b/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts index 6dc480866..c239405fc 100644 --- a/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts +++ b/server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts @@ -1,12 +1,7 @@ -import { ApiResponseProperty } from '@nestjs/swagger'; - export class OAuthConfigResponseDto { - @ApiResponseProperty() enabled!: boolean; - - @ApiResponseProperty() + passwordLoginEnabled!: boolean; url?: string; - - @ApiResponseProperty() buttonText?: string; + autoLaunch?: boolean; } diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts index 722e6c199..6cc459074 100644 --- a/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts @@ -31,6 +31,9 @@ export class SystemConfigOAuthDto { @IsBoolean() autoRegister!: boolean; + @IsBoolean() + autoLaunch!: boolean; + @IsBoolean() mobileOverrideEnabled!: boolean; diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config-password-login.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config-password-login.dto.ts new file mode 100644 index 000000000..119de65f6 --- /dev/null +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config-password-login.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class SystemConfigPasswordLoginDto { + @IsBoolean() + enabled!: boolean; +} diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts index 498d1e7b5..1bb2e736f 100644 --- a/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts @@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database'; import { ValidateNested } from 'class-validator'; import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigOAuthDto } from './system-config-oauth.dto'; +import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto'; import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto'; export class SystemConfigDto { @@ -11,6 +12,9 @@ export class SystemConfigDto { @ValidateNested() oauth!: SystemConfigOAuthDto; + @ValidateNested() + passwordLogin!: SystemConfigPasswordLoginDto; + @ValidateNested() storageTemplate!: SystemConfigStorageTemplateDto; } diff --git a/server/apps/immich/src/app.controller.ts b/server/apps/immich/src/app.controller.ts index b657baf8c..44207a609 100644 --- a/server/apps/immich/src/app.controller.ts +++ b/server/apps/immich/src/app.controller.ts @@ -1,3 +1,15 @@ -import { Controller } from '@nestjs/common'; +import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiExcludeEndpoint } from '@nestjs/swagger'; +import { ImmichConfigService } from '@app/immich-config'; + @Controller() -export class AppController {} +export class AppController { + constructor(private configService: ImmichConfigService) {} + + @ApiExcludeEndpoint() + @Post('refresh-config') + @HttpCode(HttpStatus.OK) + public reloadConfig() { + return this.configService.refreshConfig(); + } +} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index ced895fec..46db47b40 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -3,6 +3,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { UserModule } from './api-v1/user/user.module'; import { AssetModule } from './api-v1/asset/asset.module'; import { AuthModule } from './api-v1/auth/auth.module'; +import { APIKeyModule } from './api-v1/api-key/api-key.module'; import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module'; import { DeviceInfoModule } from './api-v1/device-info/device-info.module'; import { ConfigModule } from '@nestjs/config'; @@ -19,8 +20,8 @@ import { JobModule } from './api-v1/job/job.module'; import { SystemConfigModule } from './api-v1/system-config/system-config.module'; import { OAuthModule } from './api-v1/oauth/oauth.module'; import { TagModule } from './api-v1/tag/tag.module'; +import { ImmichConfigModule } from '@app/immich-config'; import { ShareModule } from './api-v1/share/share.module'; -import { APIKeyModule } from './api-v1/api-key/api-key.module'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module'; OAuthModule, ImmichJwtModule, + ImmichConfigModule, DeviceInfoModule, diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts index bf4aa8f9e..8a3580007 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; import { APIKeyService } from '../../../api-v1/api-key/api-key.service'; +import { AuthUserDto } from '../../../decorators/auth-user.decorator'; export const API_KEY_STRATEGY = 'api-key'; diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts index 916e718e2..551aa3a79 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts @@ -1,13 +1,13 @@ +import { UserEntity } from '@app/database'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt'; import { Repository } from 'typeorm'; import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto'; -import { UserEntity } from '@app/database'; import { jwtSecret } from '../../../constants/jwt.constant'; +import { AuthUserDto } from '../../../decorators/auth-user.decorator'; import { ImmichJwtService } from '../immich-jwt.service'; -import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; export const JWT_STRATEGY = 'jwt'; diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts index 41393e294..f3e79eef5 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts @@ -2,10 +2,10 @@ import { UserEntity } from '@app/database'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; -import { ShareService } from '../../../api-v1/share/share.service'; import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; import { Repository } from 'typeorm'; -import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; +import { ShareService } from '../../../api-v1/share/share.service'; +import { AuthUserDto } from '../../../decorators/auth-user.decorator'; export const PUBLIC_SHARE_STRATEGY = 'public-share'; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 92fad332a..eb36992a7 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3936,20 +3936,24 @@ "type": "object", "properties": { "enabled": { - "type": "boolean", - "readOnly": true + "type": "boolean" + }, + "passwordLoginEnabled": { + "type": "boolean" }, "url": { - "type": "string", - "readOnly": true + "type": "string" }, "buttonText": { - "type": "string", - "readOnly": true + "type": "string" + }, + "autoLaunch": { + "type": "boolean" } }, "required": [ - "enabled" + "enabled", + "passwordLoginEnabled" ] }, "OAuthCallbackDto": { @@ -4334,6 +4338,9 @@ "autoRegister": { "type": "boolean" }, + "autoLaunch": { + "type": "boolean" + }, "mobileOverrideEnabled": { "type": "boolean" }, @@ -4349,10 +4356,22 @@ "scope", "buttonText", "autoRegister", + "autoLaunch", "mobileOverrideEnabled", "mobileRedirectUri" ] }, + "SystemConfigPasswordLoginDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + }, "SystemConfigStorageTemplateDto": { "type": "object", "properties": { @@ -4373,6 +4392,9 @@ "oauth": { "$ref": "#/components/schemas/SystemConfigOAuthDto" }, + "passwordLogin": { + "$ref": "#/components/schemas/SystemConfigPasswordLoginDto" + }, "storageTemplate": { "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" } @@ -4380,6 +4402,7 @@ "required": [ "ffmpeg", "oauth", + "passwordLogin", "storageTemplate" ] }, diff --git a/server/libs/database/src/entities/system-config.entity.ts b/server/libs/database/src/entities/system-config.entity.ts index 40378b5bc..de9280e4e 100644 --- a/server/libs/database/src/entities/system-config.entity.ts +++ b/server/libs/database/src/entities/system-config.entity.ts @@ -1,7 +1,7 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_config') -export class SystemConfigEntity { +export class SystemConfigEntity { @PrimaryColumn() key!: SystemConfigKey; @@ -23,10 +23,12 @@ export enum SystemConfigKey { OAUTH_CLIENT_ID = 'oauth.clientId', OAUTH_CLIENT_SECRET = 'oauth.clientSecret', OAUTH_SCOPE = 'oauth.scope', + OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch', OAUTH_BUTTON_TEXT = 'oauth.buttonText', OAUTH_AUTO_REGISTER = 'oauth.autoRegister', OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled', OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri', + PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled', STORAGE_TEMPLATE = 'storageTemplate.template', } @@ -46,9 +48,13 @@ export interface SystemConfig { scope: string; buttonText: string; autoRegister: boolean; + autoLaunch: boolean; mobileOverrideEnabled: boolean; mobileRedirectUri: string; }; + passwordLogin: { + enabled: boolean; + }; storageTemplate: { template: string; }; diff --git a/server/libs/immich-config/src/immich-config.service.ts b/server/libs/immich-config/src/immich-config.service.ts index 6cb37e2be..856629dde 100644 --- a/server/libs/immich-config/src/immich-config.service.ts +++ b/server/libs/immich-config/src/immich-config.service.ts @@ -25,6 +25,10 @@ const defaults: SystemConfig = Object.freeze({ scope: 'openid email profile', buttonText: 'Login with OAuth', autoRegister: true, + autoLaunch: false, + }, + passwordLogin: { + enabled: true, }, storageTemplate: { diff --git a/web/package.json b/web/package.json index fd53e50ea..a67c5103f 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,7 @@ "build": "vite build", "package": "svelte-kit package", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", + "check": "svelte-check --no-tsconfig --fail-on-warnings --ignore \"src/api/open-api\"", "check:watch": "npm run check -- --watch", "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm test", diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 72e819edf..4627973a4 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1377,6 +1377,12 @@ export interface OAuthConfigResponseDto { * @memberof OAuthConfigResponseDto */ 'enabled': boolean; + /** + * + * @type {boolean} + * @memberof OAuthConfigResponseDto + */ + 'passwordLoginEnabled': boolean; /** * * @type {string} @@ -1389,6 +1395,12 @@ export interface OAuthConfigResponseDto { * @memberof OAuthConfigResponseDto */ 'buttonText'?: string; + /** + * + * @type {boolean} + * @memberof OAuthConfigResponseDto + */ + 'autoLaunch'?: boolean; } /** * @@ -1602,10 +1614,10 @@ export interface SharedLinkResponseDto { 'expiresAt': string | null; /** * - * @type {Array} + * @type {Array} * @memberof SharedLinkResponseDto */ - 'assets': Array; + 'assets': Array; /** * * @type {AlbumResponseDto} @@ -1707,6 +1719,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'oauth': SystemConfigOAuthDto; + /** + * + * @type {SystemConfigPasswordLoginDto} + * @memberof SystemConfigDto + */ + 'passwordLogin': SystemConfigPasswordLoginDto; /** * * @type {SystemConfigStorageTemplateDto} @@ -1799,6 +1817,12 @@ export interface SystemConfigOAuthDto { * @memberof SystemConfigOAuthDto */ 'autoRegister': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigOAuthDto + */ + 'autoLaunch': boolean; /** * * @type {boolean} @@ -1812,6 +1836,19 @@ export interface SystemConfigOAuthDto { */ 'mobileRedirectUri': string; } +/** + * + * @export + * @interface SystemConfigPasswordLoginDto + */ +export interface SystemConfigPasswordLoginDto { + /** + * + * @type {boolean} + * @memberof SystemConfigPasswordLoginDto + */ + 'enabled': boolean; +} /** * * @export diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index cddada7c8..cb550c31a 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -22,6 +22,15 @@ export const oauth = { const search = location.search; return search.includes('code=') || search.includes('error='); }, + isAutoLaunchDisabled: (location: Location) => { + const values = ['autoLaunch=0', 'password=1', 'password=true']; + for (const value of values) { + if (location.search.includes(value)) { + return true; + } + } + return false; + }, getConfig: (location: Location) => { const redirectUri = location.href.split('?')[0]; console.log(`OAuth Redirect URI: ${redirectUri}`); diff --git a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte new file mode 100644 index 000000000..c703aa013 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte @@ -0,0 +1,25 @@ + + + + +
+

+ Are you sure you want to disable all login methods? Login will be completely disabled. +

+ +

+ To re-enable, use a + + Server Command. +

+
+
+
diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index e2cb1cf9a..44f860db0 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -7,6 +7,7 @@ import { api, SystemConfigOAuthDto } from '@api'; import _ from 'lodash'; import { fade } from 'svelte/transition'; + import ConfirmDisableLogin from '../confirm-disable-login.svelte'; import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingSwitch from '../setting-switch.svelte'; @@ -43,26 +44,43 @@ }); } + let isConfirmOpen = false; + let handleConfirm: (value: boolean) => void; + + const openConfirmModal = () => { + return new Promise((resolve) => { + handleConfirm = (value: boolean) => { + isConfirmOpen = false; + resolve(value); + }; + isConfirmOpen = true; + }); + }; + async function saveSetting() { try { - const { data: currentConfig } = await api.systemConfigApi.getConfig(); + const { data: current } = await api.systemConfigApi.getConfig(); + + if (!current.passwordLogin.enabled && current.oauth.enabled && !oauthConfig.enabled) { + const confirmed = await openConfirmModal(); + if (!confirmed) { + return; + } + } if (!oauthConfig.mobileOverrideEnabled) { oauthConfig.mobileRedirectUri = ''; } - const result = await api.systemConfigApi.updateConfig({ - ...currentConfig, + const { data: updated } = await api.systemConfigApi.updateConfig({ + ...current, oauth: oauthConfig }); - oauthConfig = { ...result.data.oauth }; - savedConfig = { ...result.data.oauth }; + oauthConfig = { ...updated.oauth }; + savedConfig = { ...updated.oauth }; - notificationController.show({ - message: 'OAuth settings saved', - type: NotificationType.Info - }); + notificationController.show({ message: 'OAuth settings saved', type: NotificationType.Info }); } catch (error) { handleError(error, 'Unable to save OAuth settings'); } @@ -80,6 +98,13 @@ } +{#if isConfirmOpen} + handleConfirm(false)} + on:confirm={() => handleConfirm(true)} + /> +{/if} +
{#await getConfigs() then}
@@ -147,6 +172,13 @@ disabled={!oauthConfig.enabled} /> + + + import { + notificationController, + NotificationType + } from '$lib/components/shared-components/notification/notification'; + import { handleError } from '$lib/utils/handle-error'; + import { api, SystemConfigPasswordLoginDto } from '@api'; + import _ from 'lodash'; + import { fade } from 'svelte/transition'; + import ConfirmDisableLogin from '../confirm-disable-login.svelte'; + import SettingButtonsRow from '../setting-buttons-row.svelte'; + import SettingSwitch from '../setting-switch.svelte'; + + export let passwordLoginConfig: SystemConfigPasswordLoginDto; // this is the config that is being edited + + let savedConfig: SystemConfigPasswordLoginDto; + let defaultConfig: SystemConfigPasswordLoginDto; + + async function getConfigs() { + [savedConfig, defaultConfig] = await Promise.all([ + api.systemConfigApi.getConfig().then((res) => res.data.passwordLogin), + api.systemConfigApi.getDefaults().then((res) => res.data.passwordLogin) + ]); + } + + let isConfirmOpen = false; + let handleConfirm: (value: boolean) => void; + + const openConfirmModal = () => { + return new Promise((resolve) => { + handleConfirm = (value: boolean) => { + isConfirmOpen = false; + resolve(value); + }; + isConfirmOpen = true; + }); + }; + + async function saveSetting() { + try { + const { data: current } = await api.systemConfigApi.getConfig(); + + if (!current.oauth.enabled && current.passwordLogin.enabled && !passwordLoginConfig.enabled) { + const confirmed = await openConfirmModal(); + if (!confirmed) { + return; + } + } + + const { data: updated } = await api.systemConfigApi.updateConfig({ + ...current, + passwordLogin: passwordLoginConfig + }); + + passwordLoginConfig = { ...updated.passwordLogin }; + savedConfig = { ...updated.passwordLogin }; + + notificationController.show({ message: 'Settings saved', type: NotificationType.Info }); + } catch (error) { + handleError(error, 'Unable to save settings'); + } + } + + async function reset() { + const { data: resetConfig } = await api.systemConfigApi.getConfig(); + + passwordLoginConfig = { ...resetConfig.passwordLogin }; + savedConfig = { ...resetConfig.passwordLogin }; + + notificationController.show({ + message: 'Reset settings to the recent saved settings', + type: NotificationType.Info + }); + } + + async function resetToDefault() { + const { data: configs } = await api.systemConfigApi.getDefaults(); + + passwordLoginConfig = { ...configs.passwordLogin }; + defaultConfig = { ...configs.passwordLogin }; + + notificationController.show({ + message: 'Reset password settings to default', + type: NotificationType.Info + }); + } + + +{#if isConfirmOpen} + handleConfirm(false)} + on:confirm={() => handleConfirm(true)} + /> +{/if} + +
+ {#await getConfigs() then} +
+
+
+
+ + + +
+
+
+
+ {/await} +
diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 0fea23cc1..30b8521e4 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -93,7 +93,6 @@ describe('AlbumCard component', () => { expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith( 'thumbnailIdOne', ThumbnailFormat.Jpeg, - '', { responseType: 'blob' } ); expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob); diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index f2350c66b..2b78aa6f1 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -439,7 +439,7 @@ const handleDownloadSelectedAssets = async () => { await bulkDownload( - album.albumName, + album.albumName, Array.from(multiSelectAsset), () => { isMultiSelectionMode = false; diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 2e8077644..1b23a8794 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -6,7 +6,7 @@ export let album: AlbumResponseDto; export let variant: 'simple' | 'full' = 'full'; - export let searchQuery: string = ''; + export let searchQuery = ''; let albumNameArray: string[] = ['', '', '']; // This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-viewer.svelte index 73ffc0f06..ba1e580b0 100644 --- a/web/src/lib/components/asset-viewer/video-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-viewer.svelte @@ -9,7 +9,6 @@ export let publicSharedKey = ''; let asset: AssetResponseDto; - let videoPlayerNode: HTMLVideoElement; let isVideoLoading = true; let videoUrl: string; const dispatch = createEventDispatcher(); @@ -55,7 +54,6 @@ class="h-full object-contain" on:canplay={handleCanPlay} on:ended={() => dispatch('onVideoEnded')} - bind:this={videoPlayerNode} > diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte index e1ada7de9..8d7e7e723 100644 --- a/web/src/lib/components/forms/login-form.svelte +++ b/web/src/lib/components/forms/login-form.svelte @@ -1,4 +1,5 @@ diff --git a/web/src/lib/components/shared-components/portal/portal.svelte b/web/src/lib/components/shared-components/portal/portal.svelte index 0a5bf9dd7..4443896d6 100644 --- a/web/src/lib/components/shared-components/portal/portal.svelte +++ b/web/src/lib/components/shared-components/portal/portal.svelte @@ -47,9 +47,8 @@