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 | Bin 13465 -> 13537 bytes mobile/openapi/doc/OAuthConfigResponseDto.md | Bin 533 -> 585 bytes mobile/openapi/doc/SharedLinkResponseDto.md | Bin 802 -> 835 bytes mobile/openapi/doc/SystemConfigDto.md | Bin 624 -> 718 bytes mobile/openapi/doc/SystemConfigOAuthDto.md | Bin 697 -> 729 bytes .../doc/SystemConfigPasswordLoginDto.md | Bin 0 -> 423 bytes mobile/openapi/lib/api.dart | Bin 4595 -> 4647 bytes mobile/openapi/lib/api_client.dart | Bin 16112 -> 16220 bytes .../lib/model/o_auth_config_response_dto.dart | Bin 4784 -> 5864 bytes .../lib/model/shared_link_response_dto.dart | Bin 6556 -> 6512 bytes .../openapi/lib/model/system_config_dto.dart | Bin 3955 -> 4296 bytes .../lib/model/system_config_o_auth_dto.dart | Bin 5746 -> 6010 bytes .../system_config_password_login_dto.dart | Bin 0 -> 3602 bytes .../test/o_auth_config_response_dto_test.dart | Bin 782 -> 1008 bytes .../test/shared_link_response_dto_test.dart | Bin 1516 -> 1526 bytes .../openapi/test/system_config_dto_test.dart | Bin 828 -> 961 bytes .../test/system_config_o_auth_dto_test.dart | Bin 1444 -> 1547 bytes ...system_config_password_login_dto_test.dart | Bin 0 -> 604 bytes 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, 627 insertions(+), 153 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 0000000000..76ab057e89 --- /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 0a41099e3a..d8c017cade 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 a3d977c06b..10715c5a95 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 76099cd883..d68e7e9b31 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 786043e174b1147c9b2567acb6b0f6b7279d7909..5ff0099b03da439c8555dd09052f47bc2f36cde1 100644 GIT binary patch delta 61 zcmbQ4`7m>Xsi8qYVsUYKeo=}~etKq}OG$pLMoNCNzJ73JaY<^fbADc0W;%+J&9a6y F%mDeW7sdbp delta 12 TcmaEuIWu#Eso~~Y!xUx!C$I%8 diff --git a/mobile/openapi/doc/OAuthConfigResponseDto.md b/mobile/openapi/doc/OAuthConfigResponseDto.md index 8d6c3a41e81499b735ead2426e41ed22b7f98bb4..ae1d42c12fa6953ba1ae9425dec24e9a5406dcba 100644 GIT binary patch delta 89 zcmbQra*}1jZ%r;Ot%Ahj;`02W6rcR`%skh;#H5_m6fG@<8U-z_r2PCGFjJvsGApC{ gWOGJc380R|(vp0i#L~Rv3?y}v0~v)U=QAz?05BmOKL7v# delta 49 ocmX@fGL>b*Z?5Q~)WnqhyqwC|$tsNMoN(@BPey$t-a^I|0L(`cUH||9 diff --git a/mobile/openapi/doc/SharedLinkResponseDto.md b/mobile/openapi/doc/SharedLinkResponseDto.md index b27cc6dbc219d8d121ca72373d241eeec0d05776..c11a1a48966f64ce510aecf4d9e17ec14ffb6949 100644 GIT binary patch delta 62 zcmZ3)c9?C$b4J5xEiIqS;u0Ij;^Nejpw!}m{Ji2+my&!tEv;A$bV0q`6wS@w8MPS! D)MFOJ delta 29 kcmX@iwuo)Rb4CFzEuYNd5}V+XqRhN>J1wov5=`2R0GoyhNB{r; diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index 19209682fe5dd8d35cd23c4d92ff8ba384f30342..8ad2bfb9a3e8331a32148b8228b98909c546ae4c 100644 GIT binary patch delta 61 vcmeysa*lOFCL?b_VsUYKeo=}~etKr!#0Mh00muTA9T|mDMK(t=iZcQL;fxla delta 12 TcmX@d`hjIbCgbMgjO>g6B7p>D diff --git a/mobile/openapi/doc/SystemConfigOAuthDto.md b/mobile/openapi/doc/SystemConfigOAuthDto.md index dfdaa67126d89f8f5e9ebd00bd748a875033f229..745b13b79a6f3d8830c839a56e7e0da2d45b0ca8 100644 GIT binary patch delta 23 ecmdnVdXsg-Ax5sm(vp0i#L~RvjLmBpc^Lt3^az{) delta 11 Tcmcb~x|4OoA;!s%7{3DmAB6>d diff --git a/mobile/openapi/doc/SystemConfigPasswordLoginDto.md b/mobile/openapi/doc/SystemConfigPasswordLoginDto.md new file mode 100644 index 0000000000000000000000000000000000000000..682a3c6447c76bc88b5f154cfbda25bdf0dafe14 GIT binary patch literal 423 zcma)2!Ab)$5WUY=4D6vcknLSh)$KuO%c9;&Vbe{z4Q?_a8Igh?Z?YDwUNo2FP2RjW zZz`llclR;Bb#QLkyRzF`8WDqNG&$R~pnAA{;&)nK6OI;|pu zb%7$|>{7qIs4t2`Adu2 i9x2KxewyQV_p*KbXK!AHG+qa?o_|Pu8U8Y#1HdOlEQ2@z literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e2ea9592a8cf74c6a8a10d4052d8cc357f575bc6..595ca69b514f422ecec9e69957320af19c43c8bc 100644 GIT binary patch delta 35 rcmeyYyj*2N5+7edVsUYKeo;z%PJVi3-sC_Y$;p3sc{eBVO=JcD{%;Lr delta 12 TcmZ3k@>zL965r-&d=r@gBh3Wu diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1e2ef461b29ca9cfffc2134950b8897985b1c8f9..3a916af67107bcf0299e1c3fa019ef7739ad31c7 100644 GIT binary patch delta 68 zcmexRd#7%Lq9JcUVsUYKeo=}~etKr!WJPK5$$JfX&_pk)C{K%> J=3v9Qya1>(8Os0w delta 12 Tcmcap_n~%!qT%LV!}GiVEeQq} 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 29cdda644cc2e47f3e1eb86efca82be16ed09873..db6c9c9e92e45fe0a549173f3b1aa67391cfee60 100644 GIT binary patch delta 552 zcmdm>`a*ZZM#jn082R`L5{rw=^NUh^^3yZ(TqiGO)D=`vD9Oky)=Mlc$@fVt%}dUh ze2!5@I4M6rM*&s!=KG9qm^Qy+jAax6$=WL*X*tF+M-5eVUSd*CYKnrbEt)BleOcYq z71Y$Y6hI)qBqOy5$!?H(Bx5JDW}!RzDQlj#f;L#U20|rBUtVcWj)J{{fr1sX5gu&O zD%f0RizYgGHk*co4w6n=6@>AV53`jTDk#Kf73b$g7pY@2Pd!!v!>c;%ulO?46p-9G z*`8fW5@9Y_6M{SW7P~ALg1ecK;|w#p|EF-Pq6_WfZsk!xa{1(0-15lAnDUgeDJdvm PceV~NC~K{`YPq-orBcM- delta 82 zcmV-Y0ImP%EwCl9u>q6W0U?tQ0vNL<1ziD?4+RLbssnrhlb8mVlWGSslfnmTlTrv) olimnWlUfNNladK_lSB&Mv!4sM1Cy!`aVg;Yf;u0BV@u3 E0CP_*X#fBK diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 22701819a931a5780a792878e8883e176a156c10..a4e9760512c192f1c8eb4a26299496dd997c3ef1 100644 GIT binary patch delta 280 zcmew?cS3Q)Ax7SU#Ny)e{Gt?}{PfJc$?F)a+yFDKGenOB>hkVpS?__iC_W%{C@2 zE1PQmcO^8cOSZ!Al4<<4TpKj5&3^SvNn@F`smRfxmn~}xWZLw4=+1(ggDSaXrAYAatk=s5W(?eIjODBMO3uaNcU)L2onNbkkQY|LLl0np z#kl3R($W9}$=5LH(wTyAVP`x+@&|WS-a%=2!`k2iuNO zdiriiLRwgG3rl8}?^Vt*2xpvZCS!=31?S-HBU5M9xv5 zPw<^!#?u&NRTRFXYhfHvTiq!&pCgAH{dAQfuuMvjOOcIMWGNDNsdDO;U+4t+8yAl& zXeh9cv#u_N7P%-1h!|YG4&dt^%I-vxj2Zi+0g(p?Q3;b6NmFT#Tw)f#IILI+n2BY` zO1IF^?F-)+qG(XOQI^B2QzsKDNyzM5$d)|&!gIh;NU@7t*bU71Mq&MDoRBD$Jf@QV z+uRA6!@M%00sa~UZdsx!A(8e4X9fery@L=2jvuaV)Ig6ytK@?#4GsA8SqC{HNt}LP~Hj zbjW&^KcqX2R|00hgM8|fMq=$fu*vF**NfZeXa|O{-<50B7z;c-CA|DeJQ~mMHzj{f z!}mO*s>ZNm_R#^&XZnPAw(d1}q#PQ02Z~$^LT<-VbN&Vjl)1!GKO&2F{ooFPg;r}i z9dNG9Sz&m*tuf%aqPeLG>xNX9r^(dUi>OEDsnrx893BF_C&C~ZMyt}cFh<~yf*Z9a z0F!9#pGx63eeZ^yGa(^BU7>n}3AH-9n*{+#ZS%wgN-tq~?k7e|)7PISDn;_B8$rt| zN-?TScnbBCv*IRkXcZ2jX2x`OjIgq|Uv=2NA~bs?2IJ}MjBDZ0ckX)w zk|v|S(49ha_ezn!xJb7-fAnGxcU{I+y{Y?*=RS@uZtBdQ+izzyaTsZJ?+1Oy0^<3N z(Ijrj4E=nlIbXNC{(aPZRBf1uCBRo2GOl9EUzFCuE^&#tM>oW-?i_m+v=Gi(yeXh6 zU7N`=LT?X(2zA}WKPP1p+l!?bevbQSqm8nkaQi>S8g;U%WD9R`!}uNdCQ+e4oV9yyT3@flP{ANZfl&1^_!iB&+}c delta 26 hcmeys-p97VooTWxGyCL|Oxw7)6clPTt+}{rxd3mq2O|Ig diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart index 46778bfa713acc806909ad12b998135aefd7b59f..de19ef71b88cd80093e2d08579582635ba2e0d9e 100644 GIT binary patch delta 29 kcmaFE{f&FWY!(5>;^Nejpw!}m{Ji2+my-O=3t7Aw0ky*masU7T delta 19 acmeyy{f2wPY!7Zy6PNQAHY=b^`#_sTcqN delta 11 ScmX@ezK3nYE~d%$%uN6sF9c2i 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 744f55dd045900a7f8d3ec37c6f2e8b544a57fbb..ca5fadad4a1d01cf44317e996b2ff78c2999861b 100644 GIT binary patch delta 38 kcmZ3&-OaOM6AM>jX-U3MVrgD-#^gXIMI`R#1{M`Y01T=POaK4? delta 11 ScmeC?S;Ds|8u zcNaxL6v6BxOK+c(Rq{5=k{ITTWio&`g;kotOByfcpEm?^$j5?fI~@)0M}ZqfC6z{2 zD$Pn!;}|;G87vsq*r2q7^DLTHX?u~u>mA=4)^?U@{O*J%y5tSjmTUSgYD41MT&Cwr z8q1|+q98+23e=u%E}GRsqG7d?6~XM5OZITb@-5dU2#y&cY, // + ) { + 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 61633d7c90..a14c6d2ba2 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 ce38fc84e5..0778f80688 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 4a06f0ae8a..f1d93f067f 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 b84420d1b2..9234171372 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 6a671decb9..1721c7d31a 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 1e931a80ac..645c49ef5e 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 8c62f8f623..6097dcd6ff 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 6dc480866e..c239405fc0 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 722e6c199b..6cc4590744 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 0000000000..119de65f61 --- /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 498d1e7b5b..1bb2e736f8 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 b657baf8c6..44207a609d 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 ced895fec5..46db47b402 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 bf4aa8f9e7..8a35800072 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 916e718e2c..551aa3a796 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 41393e294d..f3e79eef5f 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 92fad332a3..eb36992a79 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 40378b5bcf..de9280e4e5 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 6cb37e2be8..856629dde1 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 fd53e50eaa..a67c5103fb 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 72e819edf0..4627973a43 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 cddada7c86..cb550c31a7 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 0000000000..c703aa013a --- /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 e2cb1cf9a5..44f860db0e 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 0fea23cc1b..30b8521e4f 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 f2350c66b3..2b78aa6f13 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 2e8077644c..1b23a87941 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 73ffc0f06b..ba1e580b00 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 e1ada7de92..8d7e7e723d 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 0a5bf9dd75..4443896d61 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 @@