From 5e680551b9795b8651ce6913da50556c6838ab56 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 9 Dec 2022 15:51:42 -0500 Subject: [PATCH] feat(server,web): migrate oauth settings from env to system config (#1061) --- Makefile | 3 + docs/docs/usage/oauth.md | 38 +-- mobile/openapi/.openapi-generator/FILES | 18 +- mobile/openapi/README.md | Bin 11476 -> 11576 bytes mobile/openapi/doc/SystemConfigApi.md | Bin 2826 -> 4096 bytes ...{SystemConfigKey.md => SystemConfigDto.md} | Bin 381 -> 524 bytes ...esponseDto.md => SystemConfigFFmpegDto.md} | Bin 495 -> 561 bytes ...esponseItem.md => SystemConfigOAuthDto.md} | Bin 541 -> 613 bytes mobile/openapi/lib/api.dart | Bin 4021 -> 4017 bytes mobile/openapi/lib/api/system_config_api.dart | Bin 3372 -> 4806 bytes mobile/openapi/lib/api_client.dart | Bin 15012 -> 14985 bytes mobile/openapi/lib/api_helper.dart | Bin 4052 -> 3944 bytes ...sponse_dto.dart => system_config_dto.dart} | Bin 3535 -> 3586 bytes .../lib/model/system_config_f_fmpeg_dto.dart | Bin 0 -> 4622 bytes .../openapi/lib/model/system_config_key.dart | Bin 3248 -> 0 bytes .../lib/model/system_config_o_auth_dto.dart | Bin 0 -> 4998 bytes .../model/system_config_response_item.dart | Bin 4185 -> 0 bytes .../openapi/test/system_config_api_test.dart | Bin 724 -> 841 bytes ..._test.dart => system_config_dto_test.dart} | Bin 639 -> 689 bytes .../test/system_config_f_fmpeg_dto_test.dart | Bin 0 -> 1019 bytes .../openapi/test/system_config_key_test.dart | Bin 427 -> 0 bytes .../test/system_config_o_auth_dto_test.dart | Bin 0 -> 1200 bytes .../system_config_response_item_test.dart | Bin 892 -> 0 bytes .../immich/src/api-v1/oauth/oauth.module.ts | 3 +- .../src/api-v1/oauth/oauth.service.spec.ts | 92 +++---- .../immich/src/api-v1/oauth/oauth.service.ts | 60 ++--- .../dto/system-config-ffmpeg.dto.ts | 18 ++ .../dto/system-config-oauth.dto.ts | 32 +++ .../system-config/dto/system-config.dto.ts | 16 ++ .../system-config/dto/update-system-config.ts | 20 -- .../system-config-response.dto.ts | 20 -- .../system-config/system-config.controller.ts | 12 +- .../system-config/system-config.service.ts | 21 +- .../processors/video-transcode.processor.ts | 12 +- server/immich-openapi-specs.json | 118 ++++++--- server/libs/common/src/config/app.config.ts | 13 - .../src/entities/system-config.entity.ts | 48 +++- .../1670607437008-TruncateOldConfigItems.ts | 11 + .../src/immich-config.service.ts | 97 +++---- server/nest-cli.json | 10 +- server/package.json | 2 +- server/tsconfig.json | 4 +- web/src/api/open-api/api.ts | 208 +++++++++++---- web/src/app.css | 2 +- .../settings/ffmpeg/ffmpeg-settings.svelte | 126 +++++++++ .../settings/oauth/oauth-settings.svelte | 147 +++++++++++ .../settings/setting-accordion.svelte | 56 ++++ .../settings/setting-buttons-row.svelte | 35 +++ .../settings/setting-input-field.svelte | 51 ++++ .../admin-page/settings/setting-switch.svelte | 81 ++++++ .../admin-page/settings/settings-panel.svelte | 97 ------- .../admin-page/user-management.svelte | 91 ------- .../full-screen-modal.svelte | 2 +- .../shared-components/navigation-bar.svelte | 7 +- .../side-bar/side-bar-button.svelte | 12 +- .../side-bar/side-bar.svelte | 45 +--- web/src/lib/constants.ts | 11 + web/src/lib/models/admin-sidebar-selection.ts | 13 - web/src/routes/admin/+layout.svelte | 81 +++++- web/src/routes/admin/+page.server.ts | 5 +- web/src/routes/admin/+page.svelte | 249 ------------------ .../routes/admin/jobs-status/+page.server.ts | 12 + web/src/routes/admin/jobs-status/+page.svelte | 11 + .../admin/server-status/+page.server.ts | 17 ++ .../routes/admin/server-status/+page.svelte | 29 ++ web/src/routes/admin/settings/+page.server.ts | 14 + web/src/routes/admin/settings/+page.svelte | 33 +++ .../admin/user-management/+page.server.ts | 17 ++ .../routes/admin/user-management/+page.svelte | 232 ++++++++++++++++ 69 files changed, 1489 insertions(+), 863 deletions(-) rename mobile/openapi/doc/{SystemConfigKey.md => SystemConfigDto.md} (66%) rename mobile/openapi/doc/{SystemConfigResponseDto.md => SystemConfigFFmpegDto.md} (62%) rename mobile/openapi/doc/{SystemConfigResponseItem.md => SystemConfigOAuthDto.md} (56%) rename mobile/openapi/lib/model/{system_config_response_dto.dart => system_config_dto.dart} (55%) create mode 100644 mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart delete mode 100644 mobile/openapi/lib/model/system_config_key.dart create mode 100644 mobile/openapi/lib/model/system_config_o_auth_dto.dart delete mode 100644 mobile/openapi/lib/model/system_config_response_item.dart rename mobile/openapi/test/{system_config_response_dto_test.dart => system_config_dto_test.dart} (55%) create mode 100644 mobile/openapi/test/system_config_f_fmpeg_dto_test.dart delete mode 100644 mobile/openapi/test/system_config_key_test.dart create mode 100644 mobile/openapi/test/system_config_o_auth_dto_test.dart delete mode 100644 mobile/openapi/test/system_config_response_item_test.dart create mode 100644 server/apps/immich/src/api-v1/system-config/dto/system-config-ffmpeg.dto.ts create mode 100644 server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts create mode 100644 server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts delete mode 100644 server/apps/immich/src/api-v1/system-config/dto/update-system-config.ts delete mode 100644 server/apps/immich/src/api-v1/system-config/response-dto/system-config-response.dto.ts create mode 100644 server/libs/database/src/migrations/1670607437008-TruncateOldConfigItems.ts create mode 100644 web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte create mode 100644 web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte create mode 100644 web/src/lib/components/admin-page/settings/setting-accordion.svelte create mode 100644 web/src/lib/components/admin-page/settings/setting-buttons-row.svelte create mode 100644 web/src/lib/components/admin-page/settings/setting-input-field.svelte create mode 100644 web/src/lib/components/admin-page/settings/setting-switch.svelte delete mode 100644 web/src/lib/components/admin-page/settings/settings-panel.svelte delete mode 100644 web/src/lib/components/admin-page/user-management.svelte delete mode 100644 web/src/lib/models/admin-sidebar-selection.ts create mode 100644 web/src/routes/admin/jobs-status/+page.server.ts create mode 100644 web/src/routes/admin/jobs-status/+page.svelte create mode 100644 web/src/routes/admin/server-status/+page.server.ts create mode 100644 web/src/routes/admin/server-status/+page.svelte create mode 100644 web/src/routes/admin/settings/+page.server.ts create mode 100644 web/src/routes/admin/settings/+page.svelte create mode 100644 web/src/routes/admin/user-management/+page.server.ts create mode 100644 web/src/routes/admin/user-management/+page.svelte diff --git a/Makefile b/Makefile index 34c9619c5..90e048a9b 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ dev: dev-new: rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans +dev-new-update: + rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans + dev-update: rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans diff --git a/docs/docs/usage/oauth.md b/docs/docs/usage/oauth.md index 78eb47aed..85eba70d6 100644 --- a/docs/docs/usage/oauth.md +++ b/docs/docs/usage/oauth.md @@ -28,13 +28,13 @@ Before enabling OAuth in Immich, a new client application needs to be configured 2. Configure Redirect URIs/Origins - The **Sign-in redirect URIs** should include: +The **Sign-in redirect URIs** should include: + +- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`) +- Mobile app redirect URL `app.immich:/` - * All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`) - * Mobile app redirect URL `app.immich:/` - :::caution -You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly. +You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly. **Authentik example** @@ -42,17 +42,17 @@ You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobi ## Enable OAuth -Once you have a new OAuth client application configured, Immich can be configured using the following environment variables: +Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings). -| Key | Type | Default | Description | +| Setting | Type | Default | Description | | ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- | -| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 | -| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) | -| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) | -| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step) | -| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) | -| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in | -| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web | +| OAuth enabled | boolean | false | Enable/disable OAuth2 | +| OAuth issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) | +| OAuth client ID | string | (required) | Required. Client ID (from previous step) | +| OAuth client secret | string | (required) | Required. Client Secret (previous step) | +| OAuth scope | string | openid email profile | Full list of scopes to send with the request (space delimited) | +| OAuth button text | string | Login with OAuth | Text for the OAuth button on the web | +| OAuth auto register | boolean | true | When true, will automatically register a user the first time they sign in | :::info The Issuer URL should look something like the following, and return a valid json document. @@ -63,14 +63,4 @@ 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. ::: -Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik: - -``` -OAUTH_ENABLED=true -OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich -OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368 -OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2 -OAUTH_BUTTON_TEXT=Login with Authentik -``` - [oidc]: https://openid.net/connect/ diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 15fce354b..608df54b1 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -61,9 +61,9 @@ doc/ServerVersionReponseDto.md doc/SignUpDto.md doc/SmartInfoResponseDto.md doc/SystemConfigApi.md -doc/SystemConfigKey.md -doc/SystemConfigResponseDto.md -doc/SystemConfigResponseItem.md +doc/SystemConfigDto.md +doc/SystemConfigFFmpegDto.md +doc/SystemConfigOAuthDto.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -149,9 +149,9 @@ lib/model/server_stats_response_dto.dart lib/model/server_version_reponse_dto.dart lib/model/sign_up_dto.dart lib/model/smart_info_response_dto.dart -lib/model/system_config_key.dart -lib/model/system_config_response_dto.dart -lib/model/system_config_response_item.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/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart @@ -224,9 +224,9 @@ test/server_version_reponse_dto_test.dart test/sign_up_dto_test.dart test/smart_info_response_dto_test.dart test/system_config_api_test.dart -test/system_config_key_test.dart -test/system_config_response_dto_test.dart -test/system_config_response_item_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/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a0482b3e0322a1e1ff27ce8ae5f62f9bf0649528..76b13b23293861c06028f08e0a1ab7ec91b87697 100644 GIT binary patch delta 126 zcmcZ-xg%;rwLr3ER8C8-d=(dM&NAwA_N!bS*7~8ii;rt>DVylGI%1{JgZxbT>DU3{at# sR;&hA3BBADO`tv?;?mN}Pb@9Tz-ErWBTxd1F+g#Cumr-GiPx6_08LakR{#J2 delta 17 YcmeBS`O7pxhS@u{a-#gs$?1$s0XPN*aR2}S diff --git a/mobile/openapi/doc/SystemConfigResponseDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md similarity index 62% rename from mobile/openapi/doc/SystemConfigResponseDto.md rename to mobile/openapi/doc/SystemConfigFFmpegDto.md index 506d531b96778d2c4b13a0ba7237fe08179cafb0..b208d7b9ffface6615f4a851fec658db2196b36d 100644 GIT binary patch delta 192 zcmaFQypd&s44a!krj(YHbXhM3qDVjiS5S?X-Ii)y_O-W5lEX^qi1F7Lfv3v4% G#&rNdf;NHx diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 69efb7a304ee95c2e4256b30171862c4f5daa14c..b30e4f6814864817f4480d8dea82a8938bf04935 100644 GIT binary patch delta 46 ycmdlgzfpdJD9>akcJawNJbY|v@oBjQsgoVpCBfYM_{7qZjLDAd5}P}DbeI7}NeyuT delta 49 zcmdlezg2#NC=YXXYUSjItRj(P0Jv Do2d{p diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index e228c7199f5bd5e31493e0718cc4e7b2cb24a85c..7bd66c670078b1d1c0b2a7407c9234a3f701247c 100644 GIT binary patch delta 349 zcmZ1@bxd`G1IuO)mIkKD7udumzhkqTe1cJkQ$Hm&EwMDGqPB7DFDg;*+m!#%8=jWwmrUUiGDios% zOqSyko6OC`HF-0;B)VdBwK|ivnfiqkY|+Ix=W#4yMAwYXCM9Et(@P6d5=&Acj?zGL eA-dr@lOtGEC--nk@}s$Y^GYr=m_JpSYPkSz8hn)i delta 204 zcmX@6x<+b)0}Dq`YH>k+UUBMX2bKmVIB)U+HZcTezL4tVc%Bj-|D>$cr%&E&WdAX)Gh|d$0T3nEy XSDflul9~(CfGWr+tFU>N?t2~p#(*F< diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 828ff84fcba7988450770a54a8f9eaf4a6eae8c8..c59dc0f913d9e3ab765ecd40ab8dd7d51faf6a32 100644 GIT binary patch delta 12 Tcmca2|3YqqEXU?f4sUhaZ delta 58 zcmaDMcSU}KEQfAzWpPPru5*4~T4uU;YNe(^HJ1Vq6s4Aw7Ue0R$!@;K-o*|8Yv-6XTYm~pZ-qx$45M%j9ol6;M7E(IVcN-ZqSEJ{sLD9Oky)=NvvEl5q*!6K8N zSXz<+lBm_R<^oc|mBl5gxz71{X_@J6ZXjJiGZi5EF%qZsP7>9ZB-zOB$;CjAQI}a3NV|kA7n}T>y>xN~$Ii)?oNklbIZsWF o2PjvoY0U+sd@_qmY=SF`OHy;4!OCD}cmhT2Kstb0Cx2l~(t=3<4OdW8 z1DmdpUy_kp1UDaS0Z92&ra(5RPG)9XDI}fI5Mf&tsB{8zj39)m9t&|GNdFsVD}=|? zSQf)Mn@_SVK?;Cewq&@}=kk*ui?9CyDusM;V6v4U1u@k}R=XB#n(mq*$=Qu)gKG!Y; iCz9I}!CAzeh2Ze;oQ88YU+3XvgasHl7OlBzxwrt$1HFR) diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..73692bdcead6cb45dc6dffc887c338ff2003e965 GIT binary patch literal 4622 zcmds4TW`}y6n@XII7Jn4wZ>igv?`kdqUnkPja0fwtEjT3&LkOdJZ5H06qWw>J!dYl zlT0W)t+bVz#OF5W`knFdaqsvTUjKbFdiK}wYIyznW_S)4Z{H77I3K~)=mtKF&fi}A zbAVzb`D((H8JuL#PkQtyW|dStpGuWaMafU0sT$2EIWPHwS0)Y5Vo^(Fq8hARvX##3 z#^j2BZG=MgiY@T7W(vOzR~n5g9ak@8r46f0E+%-OmTHrd-@li&_5rqX7kRq4=TDn3cA{JyM-$^bS6%$c53Q(&HKtm_G;!L4|~wPtEX zgNL&;=UQkhmS7gLpnC+rIid@Hm%bhCK8ZHCeG)#Lmc7#sz8Q^lKN8yk{pfU!F^It- zex$x|gF_cVx>KLQ;dTr`ru8|0xWzqeT~M9XzQw*|H%wW8a^xKbN>N2w*o zm3%rn8lK|Bm4#9l8Brbxa(*3SfmIW5OGkq+8osfz;Ton&F1*39B_S+NQJ_!YJ;vhP zrcgB{LJZ-_QfQ0ToiV{APH}bEt)rR(K}hEWPdP3jBZ?gN)m%;2-VZC?D2@w-8T{a|U^QR}hT%@K zg!Rt(!dIFWlvlixhQsqyE8|m0*w8mn%z5#JPXJdx`7S2Htl*BXBqsij(}t*I^^9`4 zZSI83VO#|P5q@i2D0PjdM1{MrT3$UE?E;Qr;xB2s#w7k`q%!x0jLO1;;3{6SPM|kA{dd`GK1Q;%A(-|z9_{7_nOPX2|4F^+WIHqgg zK^y@ynxae8OE82^H@C>=r>AR|=AHt|*kt1Hv7_lyO&fz;*hswBGLm9TLefql{IXW; zrtwul$Xz39yf`!~(b5EeT@DCnJ1abHD=hSG70`np7Qt3);EJ?w0TA-L7qnAYzf0u^ zQ%Iz@Gk3Yeam(GfcG=hp%-5=iR~5X66&lyb9_cx z#EXrtRnKRWOK)=yNeb?!Pnm97W49p&^tXtZOv!8;XsNj(*=CUmKw%N1U^ z77eoQxLcU`Du$5B+){6w$=)c}%I`>b4IF3p7SrrK->u)iU#k9TXFYDvJBx0E9<~+{ J@tpzz@LyRG*E;|J literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/system_config_key.dart b/mobile/openapi/lib/model/system_config_key.dart deleted file mode 100644 index 1154864bfaa09d39851d4fbbb422af7c5f5d24db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3248 zcmb7GZExE)5dQ98aRG+L0X%u@ry-r|292|2>yn~z1`LKFFw)89Dv=sV#W0He_uY|_ z?8r*I1&A$+_kPbk$MJYD9>c}`{Pz1_W;e4h7xURATwQ;fjo|VYZf@uB$L;0y)n6wh z#*!aWVcg_x^yAwB|CB4G4aS)^aVB#-he}lz(-?CsP`Od_TNb4@u2)0k8?m)1n(nenFj2s91bFxvX6ivRBY6bq3gJjv@ zs=(p_4cpD65;@#C(@J_XAM_&{LqU*8edu&}7@oo8eK6`p+vDG?R^3A?vy(|3ePS}0 z^yFYpv#cm_6_dazr2+6vtu$!Sy{;K#dX$|oD|CNSCib11d7@*&62CJR!9<}Vm%%S8v69$?Q( z?jd6-ke@rE?-%5a&}g!ee+YFC&)rh*zui@9|81Qxzgqe{>L5B!r;r(4czC5-8gl|v zKs*DnBRk)gI5$FBqL%{A@GPPxQX_|ZGg5ouf*s^Hg`8erb^ZNK*dD>R2d#5h8NCr) z+zDhNw>Ww_^P}Ai6T!908LtP%7nCKJPi8P=i$L?}+xF%S@b5_48|`LA>9&nYGO7&> z!Po2C9L?cf0+^eF{fB?(%ak&!L)0po-8m4_x<4G6xdpm1 zq+8fC2cB<0e4Is=TB}-#R>z@YSU_qNWZ7Ra@bXPJYVUV_@B(AS4 z?+h}mb23(zyL$w`;1S{|JD`}>g&#Iu=XN&)*S3>5jGNi=8}k+m!U1Jiy1XRoa2oAy z3j3Ur2#fG+)t}c*?FJXodI?0yl=ED#Ui2@jhVz%Sd-nT_mb&Zgh|T>4f5hf)86L6O zEcqk$>NUDEmSQGo1)Wphn?G*dN!!@{zOaO2G{c2iReS^YC?B5QtFMYM9&U^QnnVX- zV9((@212+UH9lTh47^}CK4Shb+jj)o939bqmyA0kN7Gi5Kf}2Hg3wW!?uhpt@0x!B D8nr~h diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..9ca02ce41494bd1d5a5aaa019eff7aad9e955a8a GIT binary patch literal 4998 zcmds5ZEq7f5dNNDF+~yC>X!82)2XllM8k=I&v8z(Vrc7yGu7nT0^oAJx8 zlNbu0PC6vo^?1gf*Js8(Iq998z{`(UlShA!&&L-puf}Ka`px@s3TG2IpIpI*$=RFN ze;*+malV){sk0};$4`2+6w9iXJYUo@Ux<>QLQ^%0&vRb#HLrB)*J8b?rH)Fl@`i0y zzG-wW`IkmW6tCDCe{Pt>-hf5U~FX~EZR_R>K@j|f>TpDo|y{r@!S9w`iOJvh4 zR^@;Eo);@7Rj-HFX{Z-aZ+OKvVu=5r_IgFhl!D8x(tQ1@t`=fBdD-aInXcis2XKN; zxZt{xl>!D8zkz9t_Zoyzv*1G%ig6@#8i02J=#@}IRAg52c^cUgN;O=5lx1wQC zO;phCUGhS59g|fBIv}<&YqYMbEB;SZj5WHx;7fsik|ce1jLrj?)pZHZ3`4loQdCQ5 z)enhY?L(SXH>4XiLqeyXp_+?Br402&{gq3Jsp3^bTP4%Age*%YvroLh6woU!k0Bd@ zO{s#F2}L9IJKw(9FEk<=N_hCt1k7^zAmR<)5qk`+FNmdE?4II%Lp<$wjK#XDV{_|W zR^vV5u^+peohOMSSnaZf&Lzfhec)_SX;^X%E2dVj>N!XE8XvV7LeNq1f-A-3mP#KJ zc*T`ahAnX^H1zVv@SELe{dKtI-SoX3>zTt{Izoml;{VH9Bo`A?tf{J2{UcfMv#60JF(3Ap>@{-W7P+? zd1j4BVZKGY7R3m3{Z`c#T4k|pAu3iv{t0Q2!2$d2-BVj8mSrjj&eXv*vb*nnmez8^ z)UNi~ye|xwU&NWLV**A?*k?8lGhF6tf4g&@;pvw9jG-?LYe=xn2+Y_|tXGI~M?m_^ zjRsgX2itcQ2(92ND;ut0A?vj}Z)!-`n-<70pawR@xL&7FHDzhh>qaQkvVuC@NWQ@C zX&SF=3ItD;6RhUghqWkjY}u=NPIYSuQH*?U^f2k-(#PuOa|cyQjCKUFsFbGz*sO9% zWmtvF$1OOCtXyZxALNqdMxn^Ve*zovL1KcgvPl#usQ=6UX4%6o9SSKKp3q%C6oC^XnJiklR%A*rD7s|a1I1o4~0UQ8Lu}ws9u7V#2sJX5Ht|0 zzn4n&;dVsJzm@lG7`C%gr0p9<^2$)c1HbUQPG1Ob^ zBlr~B_iBzvh(vk;+wm1+)kPDja_i%XuLA^suDnIR8dr1J(Bwc6rF-Iw4#W8pwHO+h z&MeY;^z>o%zjuO+qHSfN>9{1gHlC{O^XW$D6_Ti?=rtXS#v{v$l5Q%&$q_~9PYZM+ zrdA|FD68THJR%|5j^;`VKUc$eMwmrExbA4BiR-*Mz;pc+UJLD?t`vhJ3`xm ach;_-I+|^gZV@y+N7-p&M8h`_LgK&QW=s$O literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/system_config_response_item.dart b/mobile/openapi/lib/model/system_config_response_item.dart deleted file mode 100644 index f52ea6eaf0f43f276c6c4a58b5795345a78c4613..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4185 zcmds4TW{Mo6n^)wI4Oc!!Bl0<(~!<-i`E^|OAMr0fx$2YMy6x7vZ#`D4I|Be-*-sK zrenL=`Z8bvVoN;t+jr>WGgmv23Pc9@cR7C zUk6}g&KF~0T<;`%deY%nbyI00^NBY3M3wTC=GENFaV|?ala))`cQvcEaXTeMxe!a6 z*K?N}`E{-g#49nwXDtlAoirB8jomF?=*l`#xm=B5P)(FHUfk|9R;o(cywueV*xXc9 z`ES4G#Z(yE>A;*LJt1AoO4KUD|Fce~D2283acP~Lz0%c0-CRmr!>ByR3Eg&xj**B< z>E@=gB&d=L8t&sCk@D^qGK1hgu7tG`wdo)?RW{?Cq&v>P$mOo%Z=#&<tFRoxI<3GY$F@8ptTCg$_%F|*FDs=aCq1b_CXu9lEcLZL3xKD(a zo`&!HB=58*`ltKk*nQ6x9|UIOXFfQ>CwDwYKLrLPsxbwOw4|I3iM2E_de-P1dM|+= z6EJicXog7VD5Yv%VzzjY7Rq{9iF&Ozass6DAZg|WrqAX)mpL%gsUiowPW703FC6TJ zr03ojU#X^Y!N<02a4Yd1@}$DC_M9jbw5g|O$qHfJR%MfbLkxTfpkLX!kw8A-@F8g? zYC;rF06+b~3LH^d%V63FUT7!j>8a=O){rX9Sl3Z8mBkl1CM+xVU5%An(zRS_$RYVVSxbrUr7eK960MLhSX+#sKL zr41%m%UU+wb(U}~OwU(9?|Yd&Scgt?&54a`v1`(BHIyw!<9qbn6%OjnNBnU1E39 zM3h#hcWsY`SvJz*0^!klpOxV#6q;dyRdXw{U)a0E@0qd~)c&k?QUCK`CXETb;NfD{ z);1R{5JP|UHmVoLxGp0Ci8^kLQB9M_s(C^=tW1o@0m&lCkaI?O_F|&~jg-?IUec!S zl-eh+#czhDJOIZP_U)|_xAV>#wJwKDFe*k%a<95}YsTe(A3v;yFIz8EYad`UQiq=a zr?k4Q=fD&k^8G(tYEZXzWpQejZEZ7tQ_{9k{Y;I$!p!$XRIvwYjl>Oupa;F?;)Z&8tyyDc!uNZSxVH}r|d^?5Gf|SIPROkG>w9Ir3|D>$cjo_G404#G=FBv9u%uq#-{M$N;N{u>6?}0DTrGP5=M^ delta 138 zcmdnU`k!S(CL>2sYH>k+UUBN=Ohyv~XD*{sBuvJoBwt-eK|@oano9u)^z{{dGK))W zf-8$lQgfa2^U^ZYVM;xLB6bSNU@--al+?7u(wq{7vc#OyR4b5hafw27tmfpmjEZa! JMUxwu3;_FrFYy2X diff --git a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..a088dc61103e45ec77825d196b5d370bdd3badff GIT binary patch literal 1019 zcmbV~(QDf<5XRsASKOYGKvuVX8V!YpIw^s*h9w*HNfq{45w#`lbnXbF|NG8PLq}(5 zmj}x__J`yU5zHvZVyt$|Ci691N?i!>DTSjL3Mao%(XBimj%YB>Ns=6m;>Z4PN+w zP6wOHchXHgn*AeTjGT7MLC}SEbp$yQSnncZbM0Ma&2rfUG#UHk$=ebdZQXbRzY_pK zD(iF&wx6UxxEvQ6WaG1;4MNR8I^bk z52AV4(>m^4(YMzz(&8r1@}7U*ck#D%AdJZGy6Jo_7bPW}L!KS0m` literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/system_config_key_test.dart b/mobile/openapi/test/system_config_key_test.dart deleted file mode 100644 index 47271c7a781037f0ba7c85625bea09e604d6eaa8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 427 zcmZvYK}*9h7>4ip6`!YWP#4`vHUx+5WT-1>J$MSC{n`cFBz{SiBKz+q$}ZYNzVO2H z)551?-Cw9*b$nIk$o6dLobz{&EbnNfR@Z)XGt{v!bu7z>NsCB Gc4XfmDv=EU diff --git a/mobile/openapi/test/system_config_o_auth_dto_test.dart b/mobile/openapi/test/system_config_o_auth_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..d3bfed19ef14c11da960fb9d5c26098c2d12b8d9 GIT binary patch literal 1200 zcmbV~L2sKt5QXpjis{LYeGCZPDk(*;+8(DVu+IpBFntK8l7JVb-eRtE%zR zwP;nVW4X@y*0ZZO2&3n?Uk*SY+V4HcCxOl0Gd3Tct#q9(JKiPiqVGf1V`gcR29M`ql<;!Hq-_n_`wr=m%w)(P@|c*yX!*c`2ggjYi2pZsH!h9iLe=;Rd$JlQ>W@j_}Io;$H{jZ>K!c@oZ0c$PiohHlt4gXt| KScWtA9Q_6(?{ky@ literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/system_config_response_item_test.dart b/mobile/openapi/test/system_config_response_item_test.dart deleted file mode 100644 index 1fa5fd3d6b6d91e89dd777937299b127d37de116..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 892 zcmbVITT8<*6n^ioI6iHIY1Jp$7&zRN;ia%m!KWD7(=OO1F-eso`|qBl4hBv(eQ25! zzUztOD2`$LxJ-_3rVrEkcsZTG_3Uoihe-kt$rA39$?W?1h+rOhnQ`Mr=O<_9ksn1N zl|hy(!*WsL0BTuV%ovtfp>+MtEGn&x+sWYN2X8FXwPOa~YGH^jc}1t@hW-wXC2?bS z(^Dm_{ti$R`$y^k3w3?#gj9#L5B{HbEklh}9MgYXhh2$lO zP$oqf{Yvikh7nN;qiWslc(Y%joj!aU1dD|;LKfhcO9Bvrl7b2sa4Q6DsI+knym-({ z%jdR{X^81b6m^+Qk|3~dqqO2%U2n00S8Vp05aw?lZGm@Q*83X-)PLkN%z0h9r-NL! N!H1dAR>I!a(HH488nOTY diff --git a/server/apps/immich/src/api-v1/oauth/oauth.module.ts b/server/apps/immich/src/api-v1/oauth/oauth.module.ts index 103645881..8d43799c2 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.module.ts +++ b/server/apps/immich/src/api-v1/oauth/oauth.module.ts @@ -1,3 +1,4 @@ +import { ImmichConfigModule } from '@app/immich-config'; import { Module } from '@nestjs/common'; import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; import { UserModule } from '../user/user.module'; @@ -5,7 +6,7 @@ import { OAuthController } from './oauth.controller'; import { OAuthService } from './oauth.service'; @Module({ - imports: [UserModule, ImmichJwtModule], + imports: [UserModule, ImmichJwtModule, ImmichConfigModule], controllers: [OAuthController], providers: [OAuthService], exports: [OAuthService], 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 d62d44208..8d7ac78d1 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 @@ -1,24 +1,13 @@ +import { SystemConfig } from '@app/database/entities/system-config.entity'; import { UserEntity } from '@app/database/entities/user.entity'; +import { ImmichConfigService } from '@app/immich-config'; import { BadRequestException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { generators, Issuer } from 'openid-client'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; import { OAuthService } from '../oauth/oauth.service'; import { IUserRepository } from '../user/user-repository'; -interface OAuthConfig { - OAUTH_ENABLED: boolean; - OAUTH_AUTO_REGISTER: boolean; - OAUTH_ISSUER_URL: string; - OAUTH_SCOPE: string; - OAUTH_BUTTON_TEXT: string; -} - -const mockConfig = (config: Partial) => { - return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null; -}; - const email = 'user@immich.com'; const sub = 'my-auth-user-sub'; @@ -39,7 +28,7 @@ const loginResponse = { describe('OAuthService', () => { let sut: OAuthService; let userRepositoryMock: jest.Mocked; - let configServiceMock: jest.Mocked; + let immichConfigServiceMock: jest.Mocked; let immichJwtServiceMock: jest.Mocked; beforeEach(async () => { @@ -80,11 +69,11 @@ describe('OAuthService', () => { extractJwtFromCookie: jest.fn(), } as unknown as jest.Mocked; - configServiceMock = { - get: jest.fn(), - } as unknown as jest.Mocked; + immichConfigServiceMock = { + getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }), + } as unknown as jest.Mocked; - sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); + sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); }); it('should be defined', () => { @@ -94,17 +83,17 @@ describe('OAuthService', () => { describe('generateConfig', () => { it('should work when oauth is not configured', async () => { await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false }); - expect(configServiceMock.get).toHaveBeenCalled(); + expect(immichConfigServiceMock.getConfig).toHaveBeenCalled(); }); it('should generate the config', async () => { - configServiceMock.get.mockImplementation( - mockConfig({ - OAUTH_ENABLED: true, - OAUTH_BUTTON_TEXT: 'OAuth', - }), - ); - sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); + immichConfigServiceMock.getConfig.mockResolvedValue({ + oauth: { + enabled: true, + buttonText: 'OAuth', + }, + } as SystemConfig); + sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({ enabled: true, buttonText: 'OAuth', @@ -119,13 +108,13 @@ describe('OAuthService', () => { }); it('should not allow auto registering', async () => { - configServiceMock.get.mockImplementation( - mockConfig({ - OAUTH_ENABLED: true, - OAUTH_AUTO_REGISTER: false, - }), - ); - sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); + immichConfigServiceMock.getConfig.mockResolvedValue({ + oauth: { + enabled: true, + autoRegister: false, + }, + } as SystemConfig); + sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null); jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null); userRepositoryMock.getByEmail.mockResolvedValue(null); @@ -136,13 +125,13 @@ describe('OAuthService', () => { }); it('should link an existing user', async () => { - configServiceMock.get.mockImplementation( - mockConfig({ - OAUTH_ENABLED: true, - OAUTH_AUTO_REGISTER: false, - }), - ); - sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); + immichConfigServiceMock.getConfig.mockResolvedValue({ + oauth: { + enabled: true, + autoRegister: false, + }, + } as SystemConfig); + sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null); jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null); userRepositoryMock.getByEmail.mockResolvedValue(user); @@ -156,8 +145,13 @@ describe('OAuthService', () => { }); it('should allow auto registering by default', async () => { - configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true })); - sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); + immichConfigServiceMock.getConfig.mockResolvedValue({ + oauth: { + enabled: true, + autoRegister: true, + }, + } as SystemConfig); + sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null); jest.spyOn(sut['logger'], 'log').mockImplementation(() => null); userRepositoryMock.getByEmail.mockResolvedValue(null); @@ -178,13 +172,13 @@ describe('OAuthService', () => { }); it('should get the session endpoint from the discovery document', async () => { - configServiceMock.get.mockImplementation( - mockConfig({ - OAUTH_ENABLED: true, - OAUTH_ISSUER_URL: 'http://issuer', - }), - ); - sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock); + immichConfigServiceMock.getConfig.mockResolvedValue({ + oauth: { + enabled: true, + issuerUrl: 'http://issuer,', + }, + } as SystemConfig); + sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint'); }); 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 349b7d3cf..cea624f8e 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.service.ts +++ b/server/apps/immich/src/api-v1/oauth/oauth.service.ts @@ -1,5 +1,5 @@ +import { ImmichConfigService } from '@app/immich-config'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; @@ -16,43 +16,26 @@ type OAuthProfile = UserinfoResponse & { export class OAuthService { private readonly logger = new Logger(OAuthService.name); - private readonly enabled: boolean; - private readonly autoRegister: boolean; - private readonly buttonText: string; - private readonly issuerUrl: string; - private readonly clientMetadata: ClientMetadata; - private readonly scope: string; - constructor( private immichJwtService: ImmichJwtService, - configService: ConfigService, + private immichConfigService: ImmichConfigService, @Inject(USER_REPOSITORY) private userRepository: IUserRepository, - ) { - this.enabled = configService.get('OAUTH_ENABLED', false); - this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true); - this.issuerUrl = configService.get('OAUTH_ISSUER_URL', ''); - this.scope = configService.get('OAUTH_SCOPE', ''); - this.buttonText = configService.get('OAUTH_BUTTON_TEXT', ''); - - this.clientMetadata = { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - client_id: configService.get('OAUTH_CLIENT_ID')!, - client_secret: configService.get('OAUTH_CLIENT_SECRET'), - response_types: ['code'], - }; - } + ) {} public async generateConfig(dto: OAuthConfigDto): Promise { - if (!this.enabled) { + const config = await this.immichConfigService.getConfig(); + const { enabled, scope, buttonText } = config.oauth; + + if (!enabled) { return { enabled: false }; } const url = (await this.getClient()).authorizationUrl({ redirect_uri: dto.redirectUri, - scope: this.scope, + scope, state: generators.state(), }); - return { enabled: true, buttonText: this.buttonText, url }; + return { enabled: true, buttonText, url }; } public async callback(dto: OAuthCallbackDto): Promise { @@ -75,9 +58,11 @@ export class OAuthService { // register new user if (!user) { - if (!this.autoRegister) { + const config = await this.immichConfigService.getConfig(); + const { autoRegister } = config.oauth; + if (!autoRegister) { this.logger.warn( - `Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`, + `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`, ); throw new BadRequestException(`User does not exist and auto registering is disabled.`); } @@ -95,20 +80,31 @@ export class OAuthService { } public async getLogoutEndpoint(): Promise { - if (!this.enabled) { + const config = await this.immichConfigService.getConfig(); + const { enabled } = config.oauth; + + if (!enabled) { return null; } return (await this.getClient()).issuer.metadata.end_session_endpoint || null; } private async getClient() { - if (!this.enabled) { + const config = await this.immichConfigService.getConfig(); + const { enabled, clientId, clientSecret, issuerUrl } = config.oauth; + + if (!enabled) { throw new BadRequestException('OAuth2 is not enabled'); } - const issuer = await Issuer.discover(this.issuerUrl); + const metadata: ClientMetadata = { + client_id: clientId, + client_secret: clientSecret, + response_types: ['code'], + }; + + const issuer = await Issuer.discover(issuerUrl); const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[]; - const metadata = { ...this.clientMetadata }; if (algorithms[0] === 'HS256') { metadata.id_token_signed_response_alg = algorithms[0]; } diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config-ffmpeg.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config-ffmpeg.dto.ts new file mode 100644 index 000000000..2b96addb2 --- /dev/null +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config-ffmpeg.dto.ts @@ -0,0 +1,18 @@ +import { IsString } from 'class-validator'; + +export class SystemConfigFFmpegDto { + @IsString() + crf!: string; + + @IsString() + preset!: string; + + @IsString() + targetVideoCodec!: string; + + @IsString() + targetAudioCodec!: string; + + @IsString() + targetScaling!: string; +} 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 new file mode 100644 index 000000000..1df0e0cd6 --- /dev/null +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config-oauth.dto.ts @@ -0,0 +1,32 @@ +import { IsBoolean, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; + +const isEnabled = (config: SystemConfigOAuthDto) => config.enabled; + +export class SystemConfigOAuthDto { + @IsBoolean() + enabled!: boolean; + + @ValidateIf(isEnabled) + @IsNotEmpty() + @IsString() + issuerUrl!: string; + + @ValidateIf(isEnabled) + @IsNotEmpty() + @IsString() + clientId!: string; + + @ValidateIf(isEnabled) + @IsNotEmpty() + @IsString() + clientSecret!: string; + + @IsString() + scope!: string; + + @IsString() + buttonText!: string; + + @IsBoolean() + autoRegister!: 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 new file mode 100644 index 000000000..1cbb5e366 --- /dev/null +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts @@ -0,0 +1,16 @@ +import { SystemConfig } from '@app/database/entities/system-config.entity'; +import { ValidateNested } from 'class-validator'; +import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; +import { SystemConfigOAuthDto } from './system-config-oauth.dto'; + +export class SystemConfigDto { + @ValidateNested() + ffmpeg!: SystemConfigFFmpegDto; + + @ValidateNested() + oauth!: SystemConfigOAuthDto; +} + +export function mapConfig(config: SystemConfig): SystemConfigDto { + return config; +} diff --git a/server/apps/immich/src/api-v1/system-config/dto/update-system-config.ts b/server/apps/immich/src/api-v1/system-config/dto/update-system-config.ts deleted file mode 100644 index e762d6d5e..000000000 --- a/server/apps/immich/src/api-v1/system-config/dto/update-system-config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity'; -import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, ValidateNested } from 'class-validator'; - -export class UpdateSystemConfigDto { - @IsNotEmpty() - @ValidateNested({ each: true }) - config!: SystemConfigItem[]; -} - -export class SystemConfigItem { - @IsNotEmpty() - @IsEnum(SystemConfigKey) - @ApiProperty({ - enum: SystemConfigKey, - enumName: 'SystemConfigKey', - }) - key!: SystemConfigKey; - value!: SystemConfigValue; -} diff --git a/server/apps/immich/src/api-v1/system-config/response-dto/system-config-response.dto.ts b/server/apps/immich/src/api-v1/system-config/response-dto/system-config-response.dto.ts deleted file mode 100644 index 6dfd3ae1c..000000000 --- a/server/apps/immich/src/api-v1/system-config/response-dto/system-config-response.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity'; -import { ApiProperty } from '@nestjs/swagger'; - -export class SystemConfigResponseDto { - config!: SystemConfigResponseItem[]; -} - -export class SystemConfigResponseItem { - @ApiProperty({ type: 'string' }) - name!: string; - - @ApiProperty({ enumName: 'SystemConfigKey', enum: SystemConfigKey }) - key!: SystemConfigKey; - - @ApiProperty({ type: 'string' }) - value!: SystemConfigValue; - - @ApiProperty({ type: 'string' }) - defaultValue!: SystemConfigValue; -} diff --git a/server/apps/immich/src/api-v1/system-config/system-config.controller.ts b/server/apps/immich/src/api-v1/system-config/system-config.controller.ts index 48e8002fa..4b8cb2979 100644 --- a/server/apps/immich/src/api-v1/system-config/system-config.controller.ts +++ b/server/apps/immich/src/api-v1/system-config/system-config.controller.ts @@ -1,8 +1,7 @@ import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Authenticated } from '../../decorators/authenticated.decorator'; -import { UpdateSystemConfigDto } from './dto/update-system-config'; -import { SystemConfigResponseDto } from './response-dto/system-config-response.dto'; +import { SystemConfigDto } from './dto/system-config.dto'; import { SystemConfigService } from './system-config.service'; @ApiTags('System Config') @@ -13,12 +12,17 @@ export class SystemConfigController { constructor(private readonly systemConfigService: SystemConfigService) {} @Get() - getConfig(): Promise { + public getConfig(): Promise { return this.systemConfigService.getConfig(); } + @Get('defaults') + public getDefaults(): SystemConfigDto { + return this.systemConfigService.getDefaults(); + } + @Put() - async updateConfig(@Body(ValidationPipe) dto: UpdateSystemConfigDto): Promise { + public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise { return this.systemConfigService.updateConfig(dto); } } diff --git a/server/apps/immich/src/api-v1/system-config/system-config.service.ts b/server/apps/immich/src/api-v1/system-config/system-config.service.ts index d79f3f5ab..5426d5a31 100644 --- a/server/apps/immich/src/api-v1/system-config/system-config.service.ts +++ b/server/apps/immich/src/api-v1/system-config/system-config.service.ts @@ -1,20 +1,23 @@ import { Injectable } from '@nestjs/common'; import { ImmichConfigService } from 'libs/immich-config/src'; -import { UpdateSystemConfigDto } from './dto/update-system-config'; -import { SystemConfigResponseDto } from './response-dto/system-config-response.dto'; +import { mapConfig, SystemConfigDto } from './dto/system-config.dto'; @Injectable() export class SystemConfigService { constructor(private immichConfigService: ImmichConfigService) {} - async getConfig(): Promise { - const config = await this.immichConfigService.getSystemConfig(); - return { config }; + public async getConfig(): Promise { + const config = await this.immichConfigService.getConfig(); + return mapConfig(config); } - async updateConfig(dto: UpdateSystemConfigDto): Promise { - await this.immichConfigService.updateSystemConfig(dto.config); - const config = await this.immichConfigService.getSystemConfig(); - return { config }; + public getDefaults(): SystemConfigDto { + const config = this.immichConfigService.getDefaults(); + return mapConfig(config); + } + + public async updateConfig(dto: SystemConfigDto): Promise { + await this.immichConfigService.updateConfig(dto); + return this.getConfig(); } } diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index f7a6957b7..fc4c3eaf6 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -42,16 +42,16 @@ export class VideoTranscodeProcessor { } async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise { - const config = await this.immichConfigService.getSystemConfigMap(); + const config = await this.immichConfigService.getConfig(); return new Promise((resolve, reject) => { ffmpeg(asset.originalPath) .outputOptions([ - `-crf ${config.ffmpeg_crf}`, - `-preset ${config.ffmpeg_preset}`, - `-vcodec ${config.ffmpeg_target_video_codec}`, - `-acodec ${config.ffmpeg_target_audio_codec}`, - `-vf scale=${config.ffmpeg_target_scaling}`, + `-crf ${config.ffmpeg.crf}`, + `-preset ${config.ffmpeg.preset}`, + `-vcodec ${config.ffmpeg.targetVideoCodec}`, + `-acodec ${config.ffmpeg.targetAudioCodec}`, + `-vf scale=${config.ffmpeg.targetScaling}`, ]) .output(savedEncodedPath) .on('start', () => { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 29533a036..6c9b3e5ac 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2086,7 +2086,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigResponseDto" + "$ref": "#/components/schemas/SystemConfigDto" } } } @@ -2109,7 +2109,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateSystemConfigDto" + "$ref": "#/components/schemas/SystemConfigDto" } } } @@ -2120,7 +2120,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigResponseDto" + "$ref": "#/components/schemas/SystemConfigDto" + } + } + } + } + }, + "tags": [ + "System Config" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, + "/system-config/defaults": { + "get": { + "operationId": "getDefaults", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemConfigDto" } } } @@ -3568,56 +3594,82 @@ "command" ] }, - "SystemConfigKey": { - "type": "string", - "enum": [ - "ffmpeg_crf", - "ffmpeg_preset", - "ffmpeg_target_video_codec", - "ffmpeg_target_audio_codec", - "ffmpeg_target_scaling" - ] - }, - "SystemConfigResponseItem": { + "SystemConfigFFmpegDto": { "type": "object", "properties": { - "name": { + "crf": { "type": "string" }, - "key": { - "$ref": "#/components/schemas/SystemConfigKey" - }, - "value": { + "preset": { "type": "string" }, - "defaultValue": { + "targetVideoCodec": { + "type": "string" + }, + "targetAudioCodec": { + "type": "string" + }, + "targetScaling": { "type": "string" } }, "required": [ - "name", - "key", - "value", - "defaultValue" + "crf", + "preset", + "targetVideoCodec", + "targetAudioCodec", + "targetScaling" ] }, - "SystemConfigResponseDto": { + "SystemConfigOAuthDto": { "type": "object", "properties": { - "config": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SystemConfigResponseItem" - } + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "buttonText": { + "type": "string" + }, + "autoRegister": { + "type": "boolean" } }, "required": [ - "config" + "enabled", + "issuerUrl", + "clientId", + "clientSecret", + "scope", + "buttonText", + "autoRegister" ] }, - "UpdateSystemConfigDto": { + "SystemConfigDto": { "type": "object", - "properties": {} + "properties": { + "ffmpeg": { + "$ref": "#/components/schemas/SystemConfigFFmpegDto" + }, + "oauth": { + "$ref": "#/components/schemas/SystemConfigOAuthDto" + } + }, + "required": [ + "ffmpeg", + "oauth" + ] } } } diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts index 944fd10bc..44f58975b 100644 --- a/server/libs/common/src/config/app.config.ts +++ b/server/libs/common/src/config/app.config.ts @@ -16,12 +16,6 @@ const jwtSecretValidator: Joi.CustomValidator = (value) => { return value; }; -const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', { - is: true, - then: Joi.string().required(), - otherwise: Joi.string().optional(), -}); - export const immichAppConfig: ConfigModuleOptions = { envFilePath: '.env', isGlobal: true, @@ -34,12 +28,5 @@ export const immichAppConfig: ConfigModuleOptions = { DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'), - OAUTH_ENABLED: Joi.bool().valid(true, false).default(false), - OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'), - OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true), - OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED, - OAUTH_SCOPE: Joi.string().optional().default('openid email profile'), - OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED, - OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED, }), }; diff --git a/server/libs/database/src/entities/system-config.entity.ts b/server/libs/database/src/entities/system-config.entity.ts index 32503dd2b..ce3fd7a96 100644 --- a/server/libs/database/src/entities/system-config.entity.ts +++ b/server/libs/database/src/entities/system-config.entity.ts @@ -1,27 +1,47 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_config') -export class SystemConfigEntity { +export class SystemConfigEntity { @PrimaryColumn() key!: SystemConfigKey; - @Column({ type: 'varchar', nullable: true }) - value!: SystemConfigValue; + @Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } }) + value!: T; } -export type SystemConfig = SystemConfigEntity[]; +export type SystemConfigValue = any; +// dot notation matches path in `SystemConfig` export enum SystemConfigKey { - FFMPEG_CRF = 'ffmpeg_crf', - FFMPEG_PRESET = 'ffmpeg_preset', - FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg_target_video_codec', - FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg_target_audio_codec', - FFMPEG_TARGET_SCALING = 'ffmpeg_target_scaling', + FFMPEG_CRF = 'ffmpeg.crf', + FFMPEG_PRESET = 'ffmpeg.preset', + FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec', + FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec', + FFMPEG_TARGET_SCALING = 'ffmpeg.targetScaling', + OAUTH_ENABLED = 'oauth.enabled', + OAUTH_ISSUER_URL = 'oauth.issuerUrl', + OAUTH_CLIENT_ID = 'oauth.clientId', + OAUTH_CLIENT_SECRET = 'oauth.clientSecret', + OAUTH_SCOPE = 'oauth.scope', + OAUTH_BUTTON_TEXT = 'oauth.buttonText', + OAUTH_AUTO_REGISTER = 'oauth.autoRegister', } -export type SystemConfigValue = string | null; - -export interface SystemConfigItem { - key: SystemConfigKey; - value: SystemConfigValue; +export interface SystemConfig { + ffmpeg: { + crf: string; + preset: string; + targetVideoCodec: string; + targetAudioCodec: string; + targetScaling: string; + }; + oauth: { + enabled: boolean; + issuerUrl: string; + clientId: string; + clientSecret: string; + scope: string; + buttonText: string; + autoRegister: boolean; + }; } diff --git a/server/libs/database/src/migrations/1670607437008-TruncateOldConfigItems.ts b/server/libs/database/src/migrations/1670607437008-TruncateOldConfigItems.ts new file mode 100644 index 000000000..0a82783f8 --- /dev/null +++ b/server/libs/database/src/migrations/1670607437008-TruncateOldConfigItems.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class TruncateOldConfigItems1670607437008 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`TRUNCATE TABLE "system_config"`); + } + + public async down(): Promise { + // noop + } +} diff --git a/server/libs/immich-config/src/immich-config.service.ts b/server/libs/immich-config/src/immich-config.service.ts index a50086fc9..e2656f6fe 100644 --- a/server/libs/immich-config/src/immich-config.service.ts +++ b/server/libs/immich-config/src/immich-config.service.ts @@ -1,32 +1,27 @@ -import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity'; +import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import * as _ from 'lodash'; +import { DeepPartial, In, Repository } from 'typeorm'; -type SystemConfigMap = Record; - -const configDefaults: Record = { - [SystemConfigKey.FFMPEG_CRF]: { - name: 'FFmpeg Constant Rate Factor (-crf)', - value: '23', +const defaults: SystemConfig = Object.freeze({ + ffmpeg: { + crf: '23', + preset: 'ultrafast', + targetVideoCodec: 'libx264', + targetAudioCodec: 'mp3', + targetScaling: '1280:-2', }, - [SystemConfigKey.FFMPEG_PRESET]: { - name: 'FFmpeg preset (-preset)', - value: 'ultrafast', + oauth: { + enabled: false, + issuerUrl: '', + clientId: '', + clientSecret: '', + scope: 'openid email profile', + buttonText: 'Login with OAuth', + autoRegister: true, }, - [SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC]: { - name: 'FFmpeg target video codec (-vcodec)', - value: 'libx264', - }, - [SystemConfigKey.FFMPEG_TARGET_AUDIO_CODEC]: { - name: 'FFmpeg target audio codec (-acodec)', - value: 'mp3', - }, - [SystemConfigKey.FFMPEG_TARGET_SCALING]: { - name: 'FFmpeg target scaling (-vf scale=)', - value: '1280:-2', - }, -}; +}); @Injectable() export class ImmichConfigService { @@ -35,38 +30,32 @@ export class ImmichConfigService { private systemConfigRepository: Repository, ) {} - public async getSystemConfig() { - const items = this._getDefaults(); + public getDefaults(): SystemConfig { + return defaults; + } - // override default values + public async getConfig() { const overrides = await this.systemConfigRepository.find(); - for (const override of overrides) { - const item = items.find((_item) => _item.key === override.key); - if (item) { - item.value = override.value; - } + const config: DeepPartial = {}; + for (const { key, value } of overrides) { + // set via dot notation + _.set(config, key, value); } - return items; + return _.defaultsDeep(config, defaults) as SystemConfig; } - public async getSystemConfigMap(): Promise { - const items = await this.getSystemConfig(); - const map: Partial = {}; - - for (const { key, value } of items) { - map[key] = value; - } - - return map as SystemConfigMap; - } - - public async updateSystemConfig(items: SystemConfigEntity[]): Promise { - const deletes: SystemConfigEntity[] = []; + public async updateConfig(config: DeepPartial | null): Promise { const updates: SystemConfigEntity[] = []; + const deletes: SystemConfigEntity[] = []; - for (const item of items) { - if (item.value === null || item.value === this._getDefaultValue(item.key)) { + for (const key of Object.values(SystemConfigKey)) { + // get via dot notation + const item = { key, value: _.get(config, key) }; + const defaultValue = _.get(defaults, key); + const isMissing = !_.has(config, key); + + if (isMissing || item.value === null || item.value === '' || item.value === defaultValue) { deletes.push(item); continue; } @@ -82,16 +71,4 @@ export class ImmichConfigService { await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) }); } } - - private _getDefaults() { - return Object.values(SystemConfigKey).map((key) => ({ - key, - defaultValue: configDefaults[key].value, - ...configDefaults[key], - })); - } - - private _getDefaultValue(key: SystemConfigKey) { - return this._getDefaults().find((item) => item.key === key)?.value || null; - } } diff --git a/server/nest-cli.json b/server/nest-cli.json index 99f2ed151..861e733da 100644 --- a/server/nest-cli.json +++ b/server/nest-cli.json @@ -71,14 +71,14 @@ "tsConfigPath": "libs/job/tsconfig.lib.json" } }, - "system-config": { + "immich-config": { "type": "library", - "root": "libs/system-config", + "root": "libs/immich-config", "entryFile": "index", - "sourceRoot": "libs/system-config/src", + "sourceRoot": "libs/immich-config/src", "compilerOptions": { - "tsConfigPath": "libs/system-config/tsconfig.lib.json" + "tsConfigPath": "libs/immich-config/tsconfig.lib.json" } } } -} \ No newline at end of file +} diff --git a/server/package.json b/server/package.json index d137cffc5..834484af8 100644 --- a/server/package.json +++ b/server/package.json @@ -142,7 +142,7 @@ "@app/database/config": "/libs/database/src/config", "@app/common": "/libs/common/src", "^@app/job(|/.*)$": "/libs/job/src/$1", - "^@app/system-config(|/.*)$": "/libs/system-config/src/$1" + "^@app/immich-config(|/.*)$": "/libs/immich-config/src/$1" } } } diff --git a/server/tsconfig.json b/server/tsconfig.json index 8bd2202b7..ee830c64a 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -22,8 +22,8 @@ "@app/database/*": ["libs/database/src/*"], "@app/job": ["libs/job/src"], "@app/job/*": ["libs/job/src/*"], - "@app/system-config": ["libs/immich-config/src"], - "@app/system-config/*": ["libs/immich-config/src/*"] + "@app/immich-config": ["libs/immich-config/src"], + "@app/immich-config/*": ["libs/immich-config/src/*"] } }, "exclude": ["dist", "node_modules", "upload"] diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7e126a44b..fd0a12599 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1428,63 +1428,107 @@ export interface SmartInfoResponseDto { /** * * @export - * @enum {string} + * @interface SystemConfigDto */ - -export const SystemConfigKey = { - Crf: 'ffmpeg_crf', - Preset: 'ffmpeg_preset', - TargetVideoCodec: 'ffmpeg_target_video_codec', - TargetAudioCodec: 'ffmpeg_target_audio_codec', - TargetScaling: 'ffmpeg_target_scaling' -} as const; - -export type SystemConfigKey = typeof SystemConfigKey[keyof typeof SystemConfigKey]; - - -/** - * - * @export - * @interface SystemConfigResponseDto - */ -export interface SystemConfigResponseDto { +export interface SystemConfigDto { /** * - * @type {Array} - * @memberof SystemConfigResponseDto + * @type {SystemConfigFFmpegDto} + * @memberof SystemConfigDto */ - 'config': Array; + 'ffmpeg': SystemConfigFFmpegDto; + /** + * + * @type {SystemConfigOAuthDto} + * @memberof SystemConfigDto + */ + 'oauth': SystemConfigOAuthDto; } /** * * @export - * @interface SystemConfigResponseItem + * @interface SystemConfigFFmpegDto */ -export interface SystemConfigResponseItem { +export interface SystemConfigFFmpegDto { /** * * @type {string} - * @memberof SystemConfigResponseItem + * @memberof SystemConfigFFmpegDto */ - 'name': string; - /** - * - * @type {SystemConfigKey} - * @memberof SystemConfigResponseItem - */ - 'key': SystemConfigKey; + 'crf': string; /** * * @type {string} - * @memberof SystemConfigResponseItem + * @memberof SystemConfigFFmpegDto */ - 'value': string; + 'preset': string; /** * * @type {string} - * @memberof SystemConfigResponseItem + * @memberof SystemConfigFFmpegDto */ - 'defaultValue': string; + 'targetVideoCodec': string; + /** + * + * @type {string} + * @memberof SystemConfigFFmpegDto + */ + 'targetAudioCodec': string; + /** + * + * @type {string} + * @memberof SystemConfigFFmpegDto + */ + 'targetScaling': string; +} +/** + * + * @export + * @interface SystemConfigOAuthDto + */ +export interface SystemConfigOAuthDto { + /** + * + * @type {boolean} + * @memberof SystemConfigOAuthDto + */ + 'enabled': boolean; + /** + * + * @type {string} + * @memberof SystemConfigOAuthDto + */ + 'issuerUrl': string; + /** + * + * @type {string} + * @memberof SystemConfigOAuthDto + */ + 'clientId': string; + /** + * + * @type {string} + * @memberof SystemConfigOAuthDto + */ + 'clientSecret': string; + /** + * + * @type {string} + * @memberof SystemConfigOAuthDto + */ + 'scope': string; + /** + * + * @type {string} + * @memberof SystemConfigOAuthDto + */ + 'buttonText': string; + /** + * + * @type {boolean} + * @memberof SystemConfigOAuthDto + */ + 'autoRegister': boolean; } /** * @@ -5254,13 +5298,46 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config }, /** * - * @param {object} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ - updateConfig: async (body: object, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'body' is not null or undefined - assertParamExists('updateConfig', 'body', body) + getDefaults: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/system-config/defaults`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {SystemConfigDto} systemConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateConfig: async (systemConfigDto: SystemConfigDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'systemConfigDto' is not null or undefined + assertParamExists('updateConfig', 'systemConfigDto', systemConfigDto) const localVarPath = `/system-config`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -5284,7 +5361,7 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(systemConfigDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -5306,18 +5383,27 @@ export const SystemConfigApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getConfig(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * - * @param {object} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async updateConfig(body: object, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateConfig(body, options); + async getDefaults(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {SystemConfigDto} systemConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateConfig(systemConfigDto: SystemConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateConfig(systemConfigDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -5335,17 +5421,25 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getConfig(options?: any): AxiosPromise { + getConfig(options?: any): AxiosPromise { return localVarFp.getConfig(options).then((request) => request(axios, basePath)); }, /** * - * @param {object} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ - updateConfig(body: object, options?: any): AxiosPromise { - return localVarFp.updateConfig(body, options).then((request) => request(axios, basePath)); + getDefaults(options?: any): AxiosPromise { + return localVarFp.getDefaults(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {SystemConfigDto} systemConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateConfig(systemConfigDto: SystemConfigDto, options?: any): AxiosPromise { + return localVarFp.updateConfig(systemConfigDto, options).then((request) => request(axios, basePath)); }, }; }; @@ -5369,13 +5463,23 @@ export class SystemConfigApi extends BaseAPI { /** * - * @param {object} body * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SystemConfigApi */ - public updateConfig(body: object, options?: AxiosRequestConfig) { - return SystemConfigApiFp(this.configuration).updateConfig(body, options).then((request) => request(this.axios, this.basePath)); + public getDefaults(options?: AxiosRequestConfig) { + return SystemConfigApiFp(this.configuration).getDefaults(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SystemConfigDto} systemConfigDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SystemConfigApi + */ + public updateConfig(systemConfigDto: SystemConfigDto, options?: AxiosRequestConfig) { + return SystemConfigApiFp(this.configuration).updateConfig(systemConfigDto, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/app.css b/web/src/app.css index 8d7682551..766d4d19f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -59,7 +59,7 @@ input:focus-visible { @layer utilities { .immich-form-input { - @apply bg-slate-100 p-2 rounded-md dark:text-immich-dark-bg focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg; + @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-500 dark:disabled:bg-gray-900 disabled:cursor-not-allowed; } .immich-form-label { diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte new file mode 100644 index 000000000..4359784d0 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -0,0 +1,126 @@ + + +
+ {#await getConfigs() then} +
+
+ + + + + + + + + + + + +
+ {/await} +
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 new file mode 100644 index 000000000..b79533260 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -0,0 +1,147 @@ + + +
+ {#await getConfigs() then} +
+
+
+ +
+ +
+ + + + + + + + + + + +
+ +
+ + + +
+ {/await} +
diff --git a/web/src/lib/components/admin-page/settings/setting-accordion.svelte b/web/src/lib/components/admin-page/settings/setting-accordion.svelte new file mode 100644 index 000000000..ddbb9e8c8 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/setting-accordion.svelte @@ -0,0 +1,56 @@ + + +
+
+
+

+ {title} +

+ +

{subtitle}

+
+ + +
+ + {#if isOpen} +
    + +
+ {/if} +
+ + diff --git a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte new file mode 100644 index 000000000..0bdd9dc83 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte @@ -0,0 +1,35 @@ + + +
+
+ {#if showResetToDefault} + + {/if} +
+ +
+ + + +
+
diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/admin-page/settings/setting-input-field.svelte new file mode 100644 index 000000000..f45ff08f9 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/setting-input-field.svelte @@ -0,0 +1,51 @@ + + + + +
+
+ + {#if required} +
*
+ {/if} + + {#if isEdited} +
+ Unsaved change +
+ {/if} +
+ +
diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte new file mode 100644 index 000000000..de2e7fdb1 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -0,0 +1,81 @@ + + +
+
+

+ {title.toUpperCase()} +

+ +

{subtitle}

+
+ + +
+ + diff --git a/web/src/lib/components/admin-page/settings/settings-panel.svelte b/web/src/lib/components/admin-page/settings/settings-panel.svelte deleted file mode 100644 index 95b51ee4a..000000000 --- a/web/src/lib/components/admin-page/settings/settings-panel.svelte +++ /dev/null @@ -1,97 +0,0 @@ - - -
- - - - - - - - - {#each items as item, i} - - - - - {/each} - -
SettingValue
- {item.name} - - -
- -
- -
-
diff --git a/web/src/lib/components/admin-page/user-management.svelte b/web/src/lib/components/admin-page/user-management.svelte deleted file mode 100644 index f52f826a6..000000000 --- a/web/src/lib/components/admin-page/user-management.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - {#each allUsers as user, i} - - - - - - - {/each} - -
EmailFirst nameLast nameAction
{user.email}{user.firstName}{user.lastName} - {#if !isDeleted(user)} - - - {/if} - {#if isDeleted(user)} - - {/if} -
- - diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index dbde92320..5e6fad489 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -9,7 +9,7 @@
dispatch('clickOutside')}> diff --git a/web/src/lib/components/shared-components/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar.svelte index 2759dea56..b66f47099 100644 --- a/web/src/lib/components/shared-components/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar.svelte @@ -7,6 +7,7 @@ import { clickOutside } from '../../utils/click-outside'; import { api, UserResponseDto } from '@api'; import ThemeButton from './theme-button.svelte'; + import { AppRoute } from '../../constants'; export let user: UserResponseDto; export let shouldShowUploadButton = true; @@ -70,7 +71,7 @@
- {#if $page.url.pathname !== '/admin' && shouldShowUploadButton} + {#if !$page.url.pathname.includes('/admin') && shouldShowUploadButton} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-button.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-button.svelte index 3ea4b4e1f..d5247112c 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-button.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-button.svelte @@ -3,22 +3,12 @@ // TODO: why `any` here? There should be a expected type for this // eslint-disable-next-line @typescript-eslint/no-explicit-any export let logo: any; - export let actionType: AdminSideBarSelection | AppSideBarSelection; export let isSelected: boolean; import { createEventDispatcher } from 'svelte'; - import type { - AdminSideBarSelection, - AppSideBarSelection - } from '../../../models/admin-sidebar-selection'; const dispatch = createEventDispatcher(); - - const onButtonClicked = () => { - dispatch('selected', { - actionType - }); - }; + const onButtonClicked = () => dispatch('selected');
- import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection'; - import { onMount } from 'svelte'; import { page } from '$app/stores'; import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte'; import ImageOutline from 'svelte-material-icons/ImageOutline.svelte'; @@ -11,28 +9,12 @@ import { api } from '@api'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../loading-spinner.svelte'; - - let selectedAction: AppSideBarSelection; + import { AppRoute } from '../../../constants'; let showAssetCount = false; let showSharingCount = false; let showAlbumsCount = false; - // let domCount = 0; - onMount(async () => { - if ($page.route.id == 'albums') { - selectedAction = AppSideBarSelection.ALBUMS; - } else if ($page.route.id == 'photos') { - selectedAction = AppSideBarSelection.PHOTOS; - } else if ($page.route.id == 'sharing') { - selectedAction = AppSideBarSelection.SHARING; - } - - // setInterval(() => { - // domCount = document.getElementsByTagName('*').length; - // }, 500); - }); - const getAssetCount = async () => { const { data: assetCount } = await api.assetApi.getAssetCountByUserId(); @@ -56,14 +38,13 @@
{#await getAssetCount()} @@ -91,16 +71,11 @@
- +
{#await getAlbumCount()} @@ -129,16 +103,11 @@

LIBRARY

-
+
+ import { page } from '$app/stores'; + import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; + import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte'; + import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; + import Sync from 'svelte-material-icons/Sync.svelte'; + import Cog from 'svelte-material-icons/Cog.svelte'; + import Server from 'svelte-material-icons/Server.svelte'; + import StatusBox from '$lib/components/shared-components/status-box.svelte'; + import { goto } from '$app/navigation'; + import { AppRoute } from '../../lib/constants'; + + const getPageTitle = (routeId: string | null) => { + switch (routeId) { + case AppRoute.ADMIN_USER_MANAGEMENT: + return 'User Management'; + case AppRoute.ADMIN_SETTINGS: + return 'Settings'; + case AppRoute.ADMIN_JOBS: + return 'Jobs'; + case AppRoute.ADMIN_STATS: + return 'Server Stats'; + default: + return ''; + } + }; + + + + Administration - Immich + + + +
- +
+
+ goto(AppRoute.ADMIN_USER_MANAGEMENT)} + /> + goto(AppRoute.ADMIN_JOBS)} + /> + goto(AppRoute.ADMIN_SETTINGS)} + /> + goto(AppRoute.ADMIN_STATS)} + /> +
+ +
+
+ +
+
+

+ {getPageTitle($page.route.id)} +

+
+
+ +
+
+ +
+
+
+
diff --git a/web/src/routes/admin/+page.server.ts b/web/src/routes/admin/+page.server.ts index 01c8609ff..f8c599615 100644 --- a/web/src/routes/admin/+page.server.ts +++ b/web/src/routes/admin/+page.server.ts @@ -1,5 +1,4 @@ import { redirect } from '@sveltejs/kit'; -import { serverApi } from '@api'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ parent }) => { @@ -11,7 +10,5 @@ export const load: PageServerLoad = async ({ parent }) => { throw redirect(302, '/photos'); } - const { data: allUsers } = await serverApi.userApi.getAllUsers(false); - - return { user, allUsers }; + throw redirect(302, '/admin/user-management'); }; diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 42388f686..e69de29bb 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -1,249 +0,0 @@ - - - - Administration - Immich - - - - -{#if shouldShowCreateUserForm} - (shouldShowCreateUserForm = false)}> - - -{/if} - -{#if shouldShowEditUserForm} - (shouldShowEditUserForm = false)}> - - -{/if} - -{#if shouldShowDeleteConfirmDialog} - (shouldShowDeleteConfirmDialog = false)}> - - -{/if} - -{#if shouldShowRestoreDialog} - (shouldShowRestoreDialog = false)}> - - -{/if} - -{#if shouldShowInfoPanel} - (shouldShowInfoPanel = false)}> -
-

Password reset success

- -

- The user's password has been reset to the default password -
- Please inform the user, and they will need to change the password at the next log-on. -

- -
- -
-
-
-{/if} - -
-
- - - - -
- -
-
-
-
-

- {selectedAction} -

-
-
- -
-
- {#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT} - (shouldShowCreateUserForm = true)} - on:edit-user={editUserHandler} - on:delete-user={deleteUserHandler} - on:restore-user={restoreUserHandler} - /> - {/if} - {#if selectedAction === AdminSideBarSelection.JOBS} - - {/if} - {#if selectedAction === AdminSideBarSelection.SETTINGS} - - {/if} - {#if selectedAction === AdminSideBarSelection.STATS && serverStat} - - {/if} -
-
-
-
diff --git a/web/src/routes/admin/jobs-status/+page.server.ts b/web/src/routes/admin/jobs-status/+page.server.ts new file mode 100644 index 000000000..ccfbb564f --- /dev/null +++ b/web/src/routes/admin/jobs-status/+page.server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, '/auth/login'); + } else if (!user.isAdmin) { + throw redirect(302, '/photos'); + } +}; diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte new file mode 100644 index 000000000..ac4ef8164 --- /dev/null +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -0,0 +1,11 @@ + + + + Jobs Status - Immich + + +
+ +
diff --git a/web/src/routes/admin/server-status/+page.server.ts b/web/src/routes/admin/server-status/+page.server.ts new file mode 100644 index 000000000..01c8609ff --- /dev/null +++ b/web/src/routes/admin/server-status/+page.server.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit'; +import { serverApi } from '@api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, '/auth/login'); + } else if (!user.isAdmin) { + throw redirect(302, '/photos'); + } + + const { data: allUsers } = await serverApi.userApi.getAllUsers(false); + + return { user, allUsers }; +}; diff --git a/web/src/routes/admin/server-status/+page.svelte b/web/src/routes/admin/server-status/+page.svelte new file mode 100644 index 000000000..1ab8940f7 --- /dev/null +++ b/web/src/routes/admin/server-status/+page.svelte @@ -0,0 +1,29 @@ + + + + Jobs Status - Immich + + +{#if $page.data.allUsers && serverStat} + +{/if} diff --git a/web/src/routes/admin/settings/+page.server.ts b/web/src/routes/admin/settings/+page.server.ts new file mode 100644 index 000000000..fb2c1cc21 --- /dev/null +++ b/web/src/routes/admin/settings/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, '/auth/login'); + } else if (!user.isAdmin) { + throw redirect(302, '/photos'); + } + + return { user }; +}; diff --git a/web/src/routes/admin/settings/+page.svelte b/web/src/routes/admin/settings/+page.svelte new file mode 100644 index 000000000..aa2fc4c56 --- /dev/null +++ b/web/src/routes/admin/settings/+page.svelte @@ -0,0 +1,33 @@ + + +
+ {#await getConfig()} + + {:then configs} + + + + + + + + {/await} +
diff --git a/web/src/routes/admin/user-management/+page.server.ts b/web/src/routes/admin/user-management/+page.server.ts new file mode 100644 index 000000000..01c8609ff --- /dev/null +++ b/web/src/routes/admin/user-management/+page.server.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit'; +import { serverApi } from '@api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, '/auth/login'); + } else if (!user.isAdmin) { + throw redirect(302, '/photos'); + } + + const { data: allUsers } = await serverApi.userApi.getAllUsers(false); + + return { user, allUsers }; +}; diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte new file mode 100644 index 000000000..a5cb6de6e --- /dev/null +++ b/web/src/routes/admin/user-management/+page.svelte @@ -0,0 +1,232 @@ + + + + User Management - Immich + + +
+ {#if shouldShowCreateUserForm} + (shouldShowCreateUserForm = false)}> + + + {/if} + + {#if shouldShowEditUserForm} + (shouldShowEditUserForm = false)}> + + + {/if} + + {#if shouldShowDeleteConfirmDialog} + (shouldShowDeleteConfirmDialog = false)}> + + + {/if} + + {#if shouldShowRestoreDialog} + (shouldShowRestoreDialog = false)}> + + + {/if} + + {#if shouldShowInfoPanel} + (shouldShowInfoPanel = false)}> +
+

Password reset success

+ +

+ The user's password has been reset to the default password +
+ Please inform the user, and they will need to change the password at the next log-on. +

+ +
+ +
+
+
+ {/if} + + + + + + + + + + + + {#if allUsers} + {#each allUsers as user, i} + + + + + + + {/each} + {/if} + +
EmailFirst nameLast nameAction
{user.email}{user.firstName}{user.lastName} + {#if !isDeleted(user)} + + + {/if} + {#if isDeleted(user)} + + {/if} +
+ + +