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
This commit is contained in:
parent
ab0a3690f3
commit
7dc12dea1e
27 changed files with 877 additions and 205 deletions
|
@ -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
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
|
@ -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
mobile/openapi/doc/OAuthApi.md
generated
80
mobile/openapi/doc/OAuthApi.md
generated
|
@ -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
mobile/openapi/doc/UserResponseDto.md
generated
1
mobile/openapi/doc/UserResponseDto.md
generated
|
@ -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
mobile/openapi/lib/api/o_auth_api.dart
generated
88
mobile/openapi/lib/api/o_auth_api.dart
generated
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
14
mobile/openapi/lib/model/user_response_dto.dart
generated
14
mobile/openapi/lib/model/user_response_dto.dart
generated
|
@ -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
mobile/openapi/test/o_auth_api_test.dart
generated
10
mobile/openapi/test/o_auth_api_test.dart
generated
|
@ -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
mobile/openapi/test/user_response_dto_test.dart
generated
5
mobile/openapi/test/user_response_dto_test.dart
generated
|
@ -61,6 +61,11 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
// String oauthId
|
||||
test('to test the property `oauthId`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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: '',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -24,7 +24,7 @@ export class UserEntity {
|
|||
@Column({ default: '', select: false })
|
||||
salt?: string;
|
||||
|
||||
@Column({ default: '', select: false })
|
||||
@Column({ default: '' })
|
||||
oauthId!: string;
|
||||
|
||||
@Column({ default: '' })
|
||||
|
|
127
web/src/api/open-api/api.ts
generated
127
web/src/api/open-api/api.ts
generated
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 () => {
|
||||
let oauthEnabled = false;
|
||||
let oauthOpen = false;
|
||||
|
||||
onMount(async () => {
|
||||
oauthOpen = oauth.isCallback(window.location);
|
||||
|
||||
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
|
||||
});
|
||||
const { data } = await oauth.getConfig(window.location);
|
||||
oauthEnabled = data.enabled;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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
web/src/lib/utils/handle-error.ts
Normal file
13
web/src/lib/utils/handle-error.ts
Normal file
|
@ -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
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue