Jelajahi Sumber

feat(web,server): link/unlink oauth account (#1154)

* feat(web,server): link/unlink oauth account

* chore: linting

* fix: broken oauth callback

* fix: user core bugs

* fix: tests

* fix: use user response

* chore: update docs

* feat: prevent the same oauth account from being linked twice

* chore: mock logger
Jason Rasmussen 2 tahun lalu
induk
melakukan
7dc12dea1e
27 mengubah file dengan 875 tambahan dan 203 penghapusan
  1. 26 7
      docs/docs/features/oauth.md
  2. 2 0
      mobile/openapi/README.md
  3. 80 0
      mobile/openapi/doc/OAuthApi.md
  4. 1 0
      mobile/openapi/doc/UserResponseDto.md
  5. 88 0
      mobile/openapi/lib/api/o_auth_api.dart
  6. 11 3
      mobile/openapi/lib/model/user_response_dto.dart
  7. 10 0
      mobile/openapi/test/o_auth_api_test.dart
  8. 5 0
      mobile/openapi/test/user_response_dto_test.dart
  9. 1 3
      server/apps/immich/src/api-v1/auth/auth.service.ts
  10. 18 2
      server/apps/immich/src/api-v1/oauth/oauth.controller.ts
  11. 72 16
      server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
  12. 26 7
      server/apps/immich/src/api-v1/oauth/oauth.service.ts
  13. 2 0
      server/apps/immich/src/api-v1/user/response-dto/user-response.dto.ts
  14. 8 13
      server/apps/immich/src/api-v1/user/user.core.ts
  15. 19 0
      server/apps/immich/src/api-v1/user/user.service.spec.ts
  16. 3 3
      server/apps/immich/src/api-v1/user/user.service.ts
  17. 2 0
      server/apps/immich/test/user.e2e-spec.ts
  18. 57 1
      server/immich-openapi-specs.json
  19. 1 1
      server/libs/database/src/entities/user.entity.ts
  20. 127 0
      web/src/api/open-api/api.ts
  21. 27 0
      web/src/api/utils.ts
  22. 4 7
      web/src/lib/components/forms/login-form.svelte
  23. 78 0
      web/src/lib/components/user-settings-page/change-password-settings.svelte
  24. 86 0
      web/src/lib/components/user-settings-page/oauth-settings.svelte
  25. 81 0
      web/src/lib/components/user-settings-page/user-profile-settings.svelte
  26. 27 140
      web/src/lib/components/user-settings-page/user-settings-list.svelte
  27. 13 0
      web/src/lib/utils/handle-error.ts

+ 26 - 7
docs/docs/features/oauth.md

@@ -26,14 +26,33 @@ Before enabling OAuth in Immich, a new client application needs to be configured
 
 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:/`
+- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
+- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
+- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
 
-:::caution
-You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
+:::info Redirect URIs
+
+Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
+
+Mobile
+
+- `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly)
+
+Localhost
+
+- `http://localhost:2283/auth/login`
+- `http://localhost:2283/user-settings`
+
+Local IP
+
+- `http://192.168.0.200:2283/auth/login`
+- `http://192.168.0.200:2283/user-settings`
+
+Hostname
+
+- `https://immich.example.com/auth/login`)
+- `https://immich.example.com/user-settings`)
 
-**Authentik example**
-<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
 :::
 
 ## Enable OAuth
@@ -42,7 +61,7 @@ Once you have a new OAuth client application configured, Immich can be configure
 
 | Setting       | Type    | Default              | Description                                                               |
 | ------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
-| Enabled       | boolean | false                | Enable/disable OAuth                                                     |
+| 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)                                   |

+ 2 - 0
mobile/openapi/README.md

@@ -108,6 +108,8 @@ Class | Method | HTTP request | Description
 *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 
 *OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback | 
 *OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config | 
+*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | 
+*OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 

+ 80 - 0
mobile/openapi/doc/OAuthApi.md

@@ -11,6 +11,8 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**callback**](OAuthApi.md#callback) | **POST** /oauth/callback | 
 [**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config | 
+[**link**](OAuthApi.md#link) | **POST** /oauth/link | 
+[**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 
 
 # **callback**
@@ -95,3 +97,81 @@ No authorization required
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **link**
+> UserResponseDto link(oAuthCallbackDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = OAuthApi();
+final oAuthCallbackDto = OAuthCallbackDto(); // OAuthCallbackDto | 
+
+try {
+    final result = api_instance.link(oAuthCallbackDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling OAuthApi->link: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **oAuthCallbackDto** | [**OAuthCallbackDto**](OAuthCallbackDto.md)|  | 
+
+### Return type
+
+[**UserResponseDto**](UserResponseDto.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **unlink**
+> UserResponseDto unlink()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = OAuthApi();
+
+try {
+    final result = api_instance.unlink();
+    print(result);
+} catch (e) {
+    print('Exception when calling OAuthApi->unlink: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**UserResponseDto**](UserResponseDto.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+

+ 1 - 0
mobile/openapi/doc/UserResponseDto.md

@@ -17,6 +17,7 @@ Name | Type | Description | Notes
 **shouldChangePassword** | **bool** |  | 
 **isAdmin** | **bool** |  | 
 **deletedAt** | [**DateTime**](DateTime.md) |  | [optional] 
+**oauthId** | **String** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 88 - 0
mobile/openapi/lib/api/o_auth_api.dart

@@ -109,4 +109,92 @@ class OAuthApi {
     }
     return null;
   }
+
+  /// Performs an HTTP 'POST /oauth/link' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [OAuthCallbackDto] oAuthCallbackDto (required):
+  Future<Response> linkWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/oauth/link';
+
+    // ignore: prefer_final_locals
+    Object? postBody = oAuthCallbackDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [OAuthCallbackDto] oAuthCallbackDto (required):
+  Future<UserResponseDto?> link(OAuthCallbackDto oAuthCallbackDto,) async {
+    final response = await linkWithHttpInfo(oAuthCallbackDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'POST /oauth/unlink' operation and returns the [Response].
+  Future<Response> unlinkWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/oauth/unlink';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<UserResponseDto?> unlink() async {
+    final response = await unlinkWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
+    
+    }
+    return null;
+  }
 }

+ 11 - 3
mobile/openapi/lib/model/user_response_dto.dart

@@ -22,6 +22,7 @@ class UserResponseDto {
     required this.shouldChangePassword,
     required this.isAdmin,
     this.deletedAt,
+    required this.oauthId,
   });
 
   String id;
@@ -48,6 +49,8 @@ class UserResponseDto {
   ///
   DateTime? deletedAt;
 
+  String oauthId;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
      other.id == id &&
@@ -58,7 +61,8 @@ class UserResponseDto {
      other.profileImagePath == profileImagePath &&
      other.shouldChangePassword == shouldChangePassword &&
      other.isAdmin == isAdmin &&
-     other.deletedAt == deletedAt;
+     other.deletedAt == deletedAt &&
+     other.oauthId == oauthId;
 
   @override
   int get hashCode =>
@@ -71,10 +75,11 @@ class UserResponseDto {
     (profileImagePath.hashCode) +
     (shouldChangePassword.hashCode) +
     (isAdmin.hashCode) +
-    (deletedAt == null ? 0 : deletedAt!.hashCode);
+    (deletedAt == null ? 0 : deletedAt!.hashCode) +
+    (oauthId.hashCode);
 
   @override
-  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
+  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, oauthId=$oauthId]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
@@ -91,6 +96,7 @@ class UserResponseDto {
     } else {
       _json[r'deletedAt'] = null;
     }
+      _json[r'oauthId'] = oauthId;
     return _json;
   }
 
@@ -122,6 +128,7 @@ class UserResponseDto {
         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
         deletedAt: mapDateTime(json, r'deletedAt', ''),
+        oauthId: mapValueOfType<String>(json, r'oauthId')!,
       );
     }
     return null;
@@ -179,6 +186,7 @@ class UserResponseDto {
     'profileImagePath',
     'shouldChangePassword',
     'isAdmin',
+    'oauthId',
   };
 }
 

+ 10 - 0
mobile/openapi/test/o_auth_api_test.dart

@@ -27,5 +27,15 @@ void main() {
       // TODO
     });
 
+    //Future<UserResponseDto> link(OAuthCallbackDto oAuthCallbackDto) async
+    test('test link', () async {
+      // TODO
+    });
+
+    //Future<UserResponseDto> unlink() async
+    test('test unlink', () async {
+      // TODO
+    });
+
   });
 }

+ 5 - 0
mobile/openapi/test/user_response_dto_test.dart

@@ -61,6 +61,11 @@ void main() {
       // TODO
     });
 
+    // String oauthId
+    test('to test the property `oauthId`', () async {
+      // TODO
+    });
+
 
   });
 

+ 1 - 3
server/apps/immich/src/api-v1/auth/auth.service.ts

@@ -74,9 +74,7 @@ export class AuthService {
       throw new BadRequestException('Wrong password');
     }
 
-    user.password = newPassword;
-
-    return this.userCore.updateUser(authUser, user, dto);
+    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
   }
 
   public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {

+ 18 - 2
server/apps/immich/src/api-v1/oauth/oauth.controller.ts

@@ -3,8 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
 import { Response } from 'express';
 import { AuthType } from '../../constants/jwt.constant';
 import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
+import { Authenticated } from '../../decorators/authenticated.decorator';
 import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
+import { UserResponseDto } from '../user/response-dto/user-response.dto';
 import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
 import { OAuthConfigDto } from './dto/oauth-config.dto';
 import { OAuthService } from './oauth.service';
@@ -22,12 +24,26 @@ export class OAuthController {
 
   @Post('/callback')
   public async callback(
-    @GetAuthUser() authUser: AuthUserDto,
     @Res({ passthrough: true }) response: Response,
     @Body(ValidationPipe) dto: OAuthCallbackDto,
   ): Promise<LoginResponseDto> {
-    const loginResponse = await this.oauthService.callback(authUser, dto);
+    const loginResponse = await this.oauthService.login(dto);
     response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
     return loginResponse;
   }
+
+  @Authenticated()
+  @Post('link')
+  public async link(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Body(ValidationPipe) dto: OAuthCallbackDto,
+  ): Promise<UserResponseDto> {
+    return this.oauthService.link(authUser, dto);
+  }
+
+  @Authenticated()
+  @Post('unlink')
+  public async unlink(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
+    return this.oauthService.unlink(authUser);
+  }
 }

+ 72 - 16
server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts

@@ -1,7 +1,7 @@
 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 { BadRequestException, Logger } from '@nestjs/common';
 import { generators, Issuer } from 'openid-client';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
@@ -32,6 +32,21 @@ const loginResponse = {
   userEmail: 'user@immich.com,',
 } as LoginResponseDto;
 
+jest.mock('@nestjs/common', () => {
+  return {
+    ...jest.requireActual('@nestjs/common'),
+    Logger: function MockLogger() {
+      Object.assign(this as Logger, {
+        verbose: jest.fn(),
+        debug: jest.fn(),
+        log: jest.fn(),
+        warn: jest.fn(),
+        error: jest.fn(),
+      });
+    },
+  };
+});
+
 describe('OAuthService', () => {
   let sut: OAuthService;
   let userRepositoryMock: jest.Mocked<IUserRepository>;
@@ -109,9 +124,9 @@ describe('OAuthService', () => {
     });
   });
 
-  describe('callback', () => {
+  describe('login', () => {
     it('should throw an error if OAuth is not enabled', async () => {
-      await expect(sut.callback(authUser, { url: '' })).rejects.toBeInstanceOf(BadRequestException);
+      await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
     });
 
     it('should not allow auto registering', async () => {
@@ -122,10 +137,8 @@ describe('OAuthService', () => {
         },
       } 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);
-      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
+      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
         BadRequestException,
       );
       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
@@ -139,15 +152,11 @@ describe('OAuthService', () => {
         },
       } 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);
       userRepositoryMock.update.mockResolvedValue(user);
       immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 
-      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
-        loginResponse,
-      );
+      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
 
       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
       expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
@@ -161,16 +170,12 @@ describe('OAuthService', () => {
         },
       } 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);
       userRepositoryMock.getAdmin.mockResolvedValue(user);
       userRepositoryMock.create.mockResolvedValue(user);
       immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
 
-      await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
-        loginResponse,
-      );
+      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
 
       expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
       expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
@@ -178,6 +183,57 @@ describe('OAuthService', () => {
     });
   });
 
+  describe('link', () => {
+    it('should link an account', async () => {
+      immichConfigServiceMock.getConfig.mockResolvedValue({
+        oauth: {
+          enabled: true,
+          autoRegister: true,
+        },
+      } as SystemConfig);
+
+      userRepositoryMock.update.mockResolvedValue(user);
+
+      await sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' });
+
+      expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: sub });
+    });
+
+    it('should not link an already linked oauth.sub', async () => {
+      immichConfigServiceMock.getConfig.mockResolvedValue({
+        oauth: {
+          enabled: true,
+          autoRegister: true,
+        },
+      } as SystemConfig);
+
+      userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
+
+      await expect(sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+
+      expect(userRepositoryMock.update).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('unlink', () => {
+    it('should unlink an account', async () => {
+      immichConfigServiceMock.getConfig.mockResolvedValue({
+        oauth: {
+          enabled: true,
+          autoRegister: true,
+        },
+      } as SystemConfig);
+
+      userRepositoryMock.update.mockResolvedValue(user);
+
+      await sut.unlink(authUser);
+
+      expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: '' });
+    });
+  });
+
   describe('getLogoutEndpoint', () => {
     it('should return null if OAuth is not configured', async () => {
       await expect(sut.getLogoutEndpoint()).resolves.toBeNull();

+ 26 - 7
server/apps/immich/src/api-v1/oauth/oauth.service.ts

@@ -4,6 +4,7 @@ import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'op
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
+import { UserResponseDto } from '../user/response-dto/user-response.dto';
 import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
 import { UserCore } from '../user/user.core';
 import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
@@ -47,12 +48,8 @@ export class OAuthService {
     return { enabled: true, buttonText, url };
   }
 
-  public async callback(authUser: AuthUserDto, dto: OAuthCallbackDto): Promise<LoginResponseDto> {
-    const redirectUri = dto.url.split('?')[0];
-    const client = await this.getClient();
-    const params = client.callbackParams(dto.url);
-    const tokens = await client.callback(redirectUri, params, { state: params.state });
-    const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
+  public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
+    const profile = await this.callback(dto.url);
 
     this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
     let user = await this.userCore.getByOAuthId(profile.sub);
@@ -61,7 +58,7 @@ export class OAuthService {
     if (!user) {
       const emailUser = await this.userCore.getByEmail(profile.email);
       if (emailUser) {
-        user = await this.userCore.updateUser(authUser, emailUser, { oauthId: profile.sub });
+        user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
       }
     }
 
@@ -88,6 +85,20 @@ export class OAuthService {
     return this.immichJwtService.createLoginResponse(user);
   }
 
+  public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
+    const { sub: oauthId } = await this.callback(dto.url);
+    const duplicate = await this.userCore.getByOAuthId(oauthId);
+    if (duplicate && duplicate.id !== user.id) {
+      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
+      throw new BadRequestException('This OAuth account has already been linked to another user.');
+    }
+    return this.userCore.updateUser(user, user.id, { oauthId });
+  }
+
+  public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
+    return this.userCore.updateUser(user, user.id, { oauthId: '' });
+  }
+
   public async getLogoutEndpoint(): Promise<string | null> {
     const config = await this.immichConfigService.getConfig();
     const { enabled } = config.oauth;
@@ -98,6 +109,14 @@ export class OAuthService {
     return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
   }
 
+  private async callback(url: string): Promise<any> {
+    const redirectUri = url.split('?')[0];
+    const client = await this.getClient();
+    const params = client.callbackParams(url);
+    const tokens = await client.callback(redirectUri, params, { state: params.state });
+    return await client.userinfo<OAuthProfile>(tokens.access_token || '');
+  }
+
   private async getClient() {
     const config = await this.immichConfigService.getConfig();
     const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;

+ 2 - 0
server/apps/immich/src/api-v1/user/response-dto/user-response.dto.ts

@@ -10,6 +10,7 @@ export class UserResponseDto {
   shouldChangePassword!: boolean;
   isAdmin!: boolean;
   deletedAt?: Date;
+  oauthId!: string;
 }
 
 export function mapUser(entity: UserEntity): UserResponseDto {
@@ -23,5 +24,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
     shouldChangePassword: entity.shouldChangePassword,
     isAdmin: entity.isAdmin,
     deletedAt: entity.deletedAt,
+    oauthId: entity.oauthId,
   };
 }

+ 8 - 13
server/apps/immich/src/api-v1/user/user.core.ts

@@ -25,27 +25,22 @@ export class UserCore {
     return hash(password, salt);
   }
 
-  async updateUser(authUser: AuthUserDto, userToUpdate: UserEntity, data: Partial<UserEntity>): Promise<UserEntity> {
-    if (!authUser.isAdmin && (authUser.id !== userToUpdate.id || userToUpdate.id != data.id)) {
+  async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
+    if (!(authUser.isAdmin || authUser.id === id)) {
       throw new ForbiddenException('You are not allowed to update this user');
     }
 
-    // TODO: can this happen? If so we should implement a test case, otherwise remove it (also from DTO)
-    if (userToUpdate.isAdmin) {
-      const adminUser = await this.userRepository.getAdmin();
-      if (adminUser && adminUser.id !== userToUpdate.id) {
-        throw new BadRequestException('Admin user exists');
-      }
+    if (dto.isAdmin && authUser.isAdmin && authUser.id !== id) {
+      throw new BadRequestException('Admin user exists');
     }
 
     try {
-      const payload: Partial<UserEntity> = { ...data };
-      if (payload.password) {
+      if (dto.password) {
         const salt = await this.generateSalt();
-        payload.salt = salt;
-        payload.password = await this.hashPassword(payload.password, salt);
+        dto.salt = salt;
+        dto.password = await this.hashPassword(dto.password, salt);
       }
-      return this.userRepository.update(userToUpdate.id, payload);
+      return this.userRepository.update(id, dto);
     } catch (e) {
       Logger.error(e, 'Failed to update user info');
       throw new InternalServerErrorException('Failed to update user info');

+ 19 - 0
server/apps/immich/src/api-v1/user/user.service.spec.ts

@@ -141,6 +141,25 @@ describe('UserService', () => {
   });
 
   describe('Create user', () => {
+    it('should let the admin update himself', async () => {
+      const dto = { id: adminUser.id, shouldChangePassword: true, isAdmin: true };
+
+      when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValueOnce(null);
+      when(userRepositoryMock.update).calledWith(adminUser.id, dto).mockResolvedValueOnce(adminUser);
+
+      await sut.updateUser(adminUser, dto);
+
+      expect(userRepositoryMock.update).toHaveBeenCalledWith(adminUser.id, dto);
+    });
+
+    it('should not let the another user become an admin', async () => {
+      const dto = { id: immichUser.id, shouldChangePassword: true, isAdmin: true };
+
+      when(userRepositoryMock.get).calledWith(immichUser.id).mockResolvedValueOnce(immichUser);
+
+      await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException);
+    });
+
     it('should not create a user if there is no local admin account', async () => {
       when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);
 

+ 3 - 3
server/apps/immich/src/api-v1/user/user.service.ts

@@ -65,12 +65,12 @@ export class UserService {
     return mapUser(createdUser);
   }
 
-  async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
-    const user = await this.userCore.get(updateUserDto.id);
+  async updateUser(authUser: AuthUserDto, dto: UpdateUserDto): Promise<UserResponseDto> {
+    const user = await this.userCore.get(dto.id);
     if (!user) {
       throw new NotFoundException('User not found');
     }
-    const updatedUser = await this.userCore.updateUser(authUser, user, updateUserDto);
+    const updatedUser = await this.userCore.updateUser(authUser, dto.id, dto);
     return mapUser(updatedUser);
   }
 

+ 2 - 0
server/apps/immich/test/user.e2e-spec.ts

@@ -107,6 +107,7 @@ describe('User', () => {
               shouldChangePassword: true,
               profileImagePath: '',
               deletedAt: null,
+              oauthId: '',
             },
             {
               email: userTwoEmail,
@@ -118,6 +119,7 @@ describe('User', () => {
               shouldChangePassword: true,
               profileImagePath: '',
               deletedAt: null,
+              oauthId: '',
             },
           ]),
         );

+ 57 - 1
server/immich-openapi-specs.json

@@ -1826,6 +1826,58 @@
         ]
       }
     },
+    "/oauth/link": {
+      "post": {
+        "operationId": "link",
+        "parameters": [],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/OAuthCallbackDto"
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/UserResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "OAuth"
+        ]
+      }
+    },
+    "/oauth/unlink": {
+      "post": {
+        "operationId": "unlink",
+        "parameters": [],
+        "responses": {
+          "201": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/UserResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "OAuth"
+        ]
+      }
+    },
     "/device-info": {
       "post": {
         "operationId": "createDeviceInfo",
@@ -2287,6 +2339,9 @@
           "deletedAt": {
             "format": "date-time",
             "type": "string"
+          },
+          "oauthId": {
+            "type": "string"
           }
         },
         "required": [
@@ -2297,7 +2352,8 @@
           "createdAt",
           "profileImagePath",
           "shouldChangePassword",
-          "isAdmin"
+          "isAdmin",
+          "oauthId"
         ]
       },
       "CreateUserDto": {

+ 1 - 1
server/libs/database/src/entities/user.entity.ts

@@ -24,7 +24,7 @@ export class UserEntity {
   @Column({ default: '', select: false })
   salt?: string;
 
-  @Column({ default: '', select: false })
+  @Column({ default: '' })
   oauthId!: string;
 
   @Column({ default: '' })

+ 127 - 0
web/src/api/open-api/api.ts

@@ -1951,6 +1951,12 @@ export interface UserResponseDto {
      * @memberof UserResponseDto
      */
     'deletedAt'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserResponseDto
+     */
+    'oauthId': string;
 }
 /**
  * 
@@ -5059,6 +5065,70 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
 
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {OAuthCallbackDto} oAuthCallbackDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        link: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'oAuthCallbackDto' is not null or undefined
+            assertParamExists('link', 'oAuthCallbackDto', oAuthCallbackDto)
+            const localVarPath = `/oauth/link`;
+            // 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: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        unlink: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/oauth/unlink`;
+            // 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: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
             return {
                 url: toPathString(localVarUrlObj),
                 options: localVarRequestOptions,
@@ -5094,6 +5164,25 @@ export const OAuthApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {OAuthCallbackDto} oAuthCallbackDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.link(oAuthCallbackDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async unlink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.unlink(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
     }
 };
 
@@ -5122,6 +5211,23 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
         generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
             return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {OAuthCallbackDto} oAuthCallbackDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        link(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<UserResponseDto> {
+            return localVarFp.link(oAuthCallbackDto, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        unlink(options?: any): AxiosPromise<UserResponseDto> {
+            return localVarFp.unlink(options).then((request) => request(axios, basePath));
+        },
     };
 };
 
@@ -5153,6 +5259,27 @@ export class OAuthApi extends BaseAPI {
     public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
         return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
     }
+
+    /**
+     * 
+     * @param {OAuthCallbackDto} oAuthCallbackDto 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof OAuthApi
+     */
+    public link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
+        return OAuthApiFp(this.configuration).link(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof OAuthApi
+     */
+    public unlink(options?: AxiosRequestConfig) {
+        return OAuthApiFp(this.configuration).unlink(options).then((request) => request(this.axios, this.basePath));
+    }
 }
 
 

+ 27 - 0
web/src/api/utils.ts

@@ -1,3 +1,7 @@
+import { AxiosError, AxiosPromise } from 'axios';
+import { api } from './api';
+import { UserResponseDto } from './open-api';
+
 const _basePath = '/api';
 
 export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) {
@@ -9,3 +13,26 @@ export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean)
 
 	return urlObj.href;
 }
+
+export type ApiError = AxiosError<{ message: string }>;
+
+export const oauth = {
+	isCallback: (location: Location) => {
+		const search = location.search;
+		return search.includes('code=') || search.includes('error=');
+	},
+	getConfig: (location: Location) => {
+		const redirectUri = location.href.split('?')[0];
+		console.log(`OAuth Redirect URI: ${redirectUri}`);
+		return api.oauthApi.generateConfig({ redirectUri });
+	},
+	login: (location: Location) => {
+		return api.oauthApi.callback({ url: location.href });
+	},
+	link: (location: Location): AxiosPromise<UserResponseDto> => {
+		return api.oauthApi.link({ url: location.href });
+	},
+	unlink: () => {
+		return api.oauthApi.unlink();
+	}
+};

+ 4 - 7
web/src/lib/components/forms/login-form.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 	import { loginPageMessage } from '$lib/constants';
-	import { api, OAuthConfigResponseDto } from '@api';
+	import { api, oauth, OAuthConfigResponseDto } from '@api';
 	import { createEventDispatcher, onMount } from 'svelte';
 
 	let error: string;
@@ -14,11 +14,10 @@
 	const dispatch = createEventDispatcher();
 
 	onMount(async () => {
-		const search = window.location.search;
-		if (search.includes('code=') || search.includes('error=')) {
+		if (oauth.isCallback(window.location)) {
 			try {
 				loading = true;
-				await api.oauthApi.callback({ url: window.location.href });
+				await oauth.login(window.location);
 				dispatch('success');
 				return;
 			} catch (e) {
@@ -29,9 +28,7 @@
 		}
 
 		try {
-			const redirectUri = window.location.href.split('?')[0];
-			console.log(`OAuth Redirect URI: ${redirectUri}`);
-			const { data } = await api.oauthApi.generateConfig({ redirectUri });
+			const { data } = await oauth.getConfig(window.location);
 			oauthConfig = data;
 		} catch (e) {
 			console.error('Error [login-form] [oauth.generateConfig]', e);

+ 78 - 0
web/src/lib/components/user-settings-page/change-password-settings.svelte

@@ -0,0 +1,78 @@
+<script lang="ts">
+	import {
+		notificationController,
+		NotificationType
+	} from '$lib/components/shared-components/notification/notification';
+	import { api, ApiError } from '@api';
+	import { fade } from 'svelte/transition';
+	import SettingInputField, {
+		SettingInputFieldType
+	} from '../admin-page/settings/setting-input-field.svelte';
+
+	let password = '';
+	let newPassword = '';
+	let confirmPassword = '';
+
+	const handleChangePassword = async () => {
+		try {
+			await api.authenticationApi.changePassword({
+				password,
+				newPassword
+			});
+
+			notificationController.show({
+				message: 'Updated password',
+				type: NotificationType.Info
+			});
+
+			password = '';
+			newPassword = '';
+			confirmPassword = '';
+		} catch (error) {
+			console.error('Error [user-profile] [changePassword]', error);
+			notificationController.show({
+				message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
+				type: NotificationType.Error
+			});
+		}
+	};
+</script>
+
+<section class="my-4">
+	<div in:fade={{ duration: 500 }}>
+		<form autocomplete="off" on:submit|preventDefault>
+			<div class="flex flex-col gap-4 ml-4 mt-4">
+				<SettingInputField
+					inputType={SettingInputFieldType.PASSWORD}
+					label="Password"
+					bind:value={password}
+					required={true}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.PASSWORD}
+					label="New password"
+					bind:value={newPassword}
+					required={true}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.PASSWORD}
+					label="Confirm password"
+					bind:value={confirmPassword}
+					required={true}
+				/>
+
+				<div class="flex justify-end">
+					<button
+						type="submit"
+						disabled={!(password && newPassword && newPassword === confirmPassword)}
+						on:click={() => handleChangePassword()}
+						class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
+						>Save
+					</button>
+				</div>
+			</div>
+		</form>
+	</div>
+</section>

+ 86 - 0
web/src/lib/components/user-settings-page/oauth-settings.svelte

@@ -0,0 +1,86 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { oauth, OAuthConfigResponseDto, UserResponseDto } from '@api';
+	import { onMount } from 'svelte';
+	import { fade } from 'svelte/transition';
+	import { handleError } from '../../utils/handle-error';
+	import LoadingSpinner from '../shared-components/loading-spinner.svelte';
+	import {
+		notificationController,
+		NotificationType
+	} from '../shared-components/notification/notification';
+
+	export let user: UserResponseDto;
+
+	let config: OAuthConfigResponseDto = { enabled: false };
+	let loading = true;
+
+	onMount(async () => {
+		if (oauth.isCallback(window.location)) {
+			try {
+				loading = true;
+
+				const { data } = await oauth.link(window.location);
+				user = data;
+
+				notificationController.show({
+					message: 'Linked OAuth account',
+					type: NotificationType.Info
+				});
+			} catch (error) {
+				handleError(error, 'Unable to link OAuth account');
+			} finally {
+				goto('?open=oauth');
+			}
+		}
+
+		try {
+			const { data } = await oauth.getConfig(window.location);
+			config = data;
+		} catch (error) {
+			handleError(error, 'Unable to load OAuth config');
+		}
+
+		loading = false;
+	});
+
+	const handleUnlink = async () => {
+		try {
+			const { data } = await oauth.unlink();
+			user = data;
+			notificationController.show({
+				message: 'Unlinked OAuth account',
+				type: NotificationType.Info
+			});
+		} catch (error) {
+			handleError(error, 'Unable to unlink account');
+		}
+	};
+</script>
+
+<section class="my-4">
+	<div in:fade={{ duration: 500 }}>
+		<div class="flex justify-end">
+			{#if loading}
+				<div class="flex place-items-center place-content-center">
+					<LoadingSpinner />
+				</div>
+			{:else if config.enabled}
+				{#if user.oauthId}
+					<button
+						on:click={() => handleUnlink()}
+						class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
+						>Unlink OAuth
+					</button>
+				{:else}
+					<a href={config.url}>
+						<button
+							class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
+							>Link to OAuth</button
+						>
+					</a>
+				{/if}
+			{/if}
+		</div>
+	</div>
+</section>

+ 81 - 0
web/src/lib/components/user-settings-page/user-profile-settings.svelte

@@ -0,0 +1,81 @@
+<script lang="ts">
+	import {
+		notificationController,
+		NotificationType
+	} from '$lib/components/shared-components/notification/notification';
+	import { api, UserResponseDto } from '@api';
+	import { fade } from 'svelte/transition';
+	import SettingInputField, {
+		SettingInputFieldType
+	} from '../admin-page/settings/setting-input-field.svelte';
+
+	export let user: UserResponseDto;
+
+	const handleSaveProfile = async () => {
+		try {
+			const { data } = await api.userApi.updateUser({
+				id: user.id,
+				firstName: user.firstName,
+				lastName: user.lastName
+			});
+
+			Object.assign(user, data);
+
+			notificationController.show({
+				message: 'Saved profile',
+				type: NotificationType.Info
+			});
+		} catch (error) {
+			console.error('Error [user-profile] [updateProfile]', error);
+			notificationController.show({
+				message: 'Unable to save profile',
+				type: NotificationType.Error
+			});
+		}
+	};
+</script>
+
+<section class="my-4">
+	<div in:fade={{ duration: 500 }}>
+		<form autocomplete="off" on:submit|preventDefault>
+			<div class="flex flex-col gap-4 ml-4 mt-4">
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="User ID"
+					bind:value={user.id}
+					disabled={true}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="Email"
+					bind:value={user.email}
+					disabled={true}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="First name"
+					bind:value={user.firstName}
+					required={true}
+				/>
+
+				<SettingInputField
+					inputType={SettingInputFieldType.TEXT}
+					label="Last name"
+					bind:value={user.lastName}
+					required={true}
+				/>
+
+				<div class="flex justify-end">
+					<button
+						type="submit"
+						on:click={() => handleSaveProfile()}
+						class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
+						>Save
+					</button>
+				</div>
+			</div>
+		</form>
+	</div>
+</section>

+ 27 - 140
web/src/lib/components/user-settings-page/user-settings-list.svelte

@@ -1,156 +1,43 @@
 <script lang="ts">
-	import {
-		notificationController,
-		NotificationType
-	} from '$lib/components/shared-components/notification/notification';
-	import { api, UserResponseDto } from '@api';
-	import { AxiosError } from 'axios';
-	import { fade } from 'svelte/transition';
+	import { page } from '$app/stores';
+	import { oauth, UserResponseDto } from '@api';
+	import { onMount } from 'svelte';
 	import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
-	import SettingInputField, {
-		SettingInputFieldType
-	} from '../admin-page/settings/setting-input-field.svelte';
-
-	type ApiError = AxiosError<{ message: string }>;
+	import ChangePasswordSettings from './change-password-settings.svelte';
+	import OAuthSettings from './oauth-settings.svelte';
+	import UserProfileSettings from './user-profile-settings.svelte';
 
 	export let user: UserResponseDto;
 
-	const handleSaveProfile = async () => {
-		try {
-			const { data } = await api.userApi.updateUser({
-				id: user.id,
-				firstName: user.firstName,
-				lastName: user.lastName
-			});
-
-			Object.assign(user, data);
-
-			notificationController.show({
-				message: 'Saved profile',
-				type: NotificationType.Info
-			});
-		} catch (error) {
-			console.error('Error [user-profile] [updateProfile]', error);
-			notificationController.show({
-				message: 'Unable to save profile',
-				type: NotificationType.Error
-			});
-		}
-	};
+	let oauthEnabled = false;
+	let oauthOpen = false;
 
-	let password = '';
-	let newPassword = '';
-	let confirmPassword = '';
+	onMount(async () => {
+		oauthOpen = oauth.isCallback(window.location);
 
-	const handleChangePassword = async () => {
 		try {
-			await api.authenticationApi.changePassword({
-				password,
-				newPassword
-			});
-
-			notificationController.show({
-				message: 'Updated password',
-				type: NotificationType.Info
-			});
-
-			password = '';
-			newPassword = '';
-			confirmPassword = '';
-		} catch (error) {
-			console.error('Error [user-profile] [changePassword]', error);
-			notificationController.show({
-				message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
-				type: NotificationType.Error
-			});
+			const { data } = await oauth.getConfig(window.location);
+			oauthEnabled = data.enabled;
+		} catch {
+			// noop
 		}
-	};
+	});
 </script>
 
 <SettingAccordion title="User Profile" subtitle="View and manage your profile">
-	<section class="my-4">
-		<div in:fade={{ duration: 500 }}>
-			<form autocomplete="off" on:submit|preventDefault>
-				<div class="flex flex-col gap-4 ml-4 mt-4">
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="User ID"
-						bind:value={user.id}
-						disabled={true}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="Email"
-						bind:value={user.email}
-						disabled={true}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="First name"
-						bind:value={user.firstName}
-						required={true}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.TEXT}
-						label="Last name"
-						bind:value={user.lastName}
-						required={true}
-					/>
-
-					<div class="flex justify-end">
-						<button
-							type="submit"
-							on:click={() => handleSaveProfile()}
-							class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
-							>Save
-						</button>
-					</div>
-				</div>
-			</form>
-		</div>
-	</section>
+	<UserProfileSettings {user} />
 </SettingAccordion>
 
 <SettingAccordion title="Password" subtitle="Change your password">
-	<section class="my-4">
-		<div in:fade={{ duration: 500 }}>
-			<form autocomplete="off" on:submit|preventDefault>
-				<div class="flex flex-col gap-4 ml-4 mt-4">
-					<SettingInputField
-						inputType={SettingInputFieldType.PASSWORD}
-						label="Password"
-						bind:value={password}
-						required={true}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.PASSWORD}
-						label="New password"
-						bind:value={newPassword}
-						required={true}
-					/>
-
-					<SettingInputField
-						inputType={SettingInputFieldType.PASSWORD}
-						label="Confirm password"
-						bind:value={confirmPassword}
-						required={true}
-					/>
-
-					<div class="flex justify-end">
-						<button
-							type="submit"
-							disabled={!(password && newPassword && newPassword === confirmPassword)}
-							on:click={() => handleChangePassword()}
-							class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
-							>Save
-						</button>
-					</div>
-				</div>
-			</form>
-		</div>
-	</section>
+	<ChangePasswordSettings />
 </SettingAccordion>
+
+{#if oauthEnabled}
+	<SettingAccordion
+		title="OAuth"
+		subtitle="Manage your linked account"
+		isOpen={oauthOpen || $page.url.searchParams.get('open') === 'oauth'}
+	>
+		<OAuthSettings {user} />
+	</SettingAccordion>
+{/if}

+ 13 - 0
web/src/lib/utils/handle-error.ts

@@ -0,0 +1,13 @@
+import { ApiError } from '../../api';
+import {
+	notificationController,
+	NotificationType
+} from '../components/shared-components/notification/notification';
+
+export function handleError(error: unknown, message: string) {
+	console.error(`[handleError]: ${message}`, error);
+	notificationController.show({
+		message: (error as ApiError)?.response?.data?.message || message,
+		type: NotificationType.Error
+	});
+}