mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
feat(web,server): disable password login (#1223)
* feat(web,server): disable password login * chore: unit tests * chore: fix import * chore: linting * feat(cli): server command for enable/disable password login * chore: update docs * feat(web): confirm dialogue * chore: linting * chore: linting * chore: linting * chore: linting * chore: linting * chore: fix web test * chore: server unit tests
This commit is contained in:
parent
5999af6c78
commit
bd838a71d1
BIN
docs/docs/features/img/password-login-settings.png
Normal file
BIN
docs/docs/features/img/password-login-settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
docs/docs/features/img/user-management-update.png
Normal file
BIN
docs/docs/features/img/user-management-update.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
@ -60,15 +60,16 @@ Before enabling OAuth in Immich, a new client application needs to be configured
|
|||||||
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
|
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
|
||||||
|
|
||||||
| Setting | Type | Default | Description |
|
| 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) |
|
| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
|
||||||
| Client ID | string | (required) | Required. Client ID (from previous step) |
|
| Client ID | string | (required) | Required. Client ID (from previous step) |
|
||||||
| Client secret | string | (required) | Required. Client Secret (previous step) |
|
| Client Secret | string | (required) | Required. Client Secret (previous step) |
|
||||||
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||||
| Button text | string | Login with OAuth | Text for the OAuth button on the web |
|
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
||||||
| Auto register | boolean | true | When true, will automatically register a user the first time they sign in |
|
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||||
| Mobile Redirect URI Override | URL | (empty) | Http(s) alternative mobile redirect URI |
|
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
||||||
|
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
The Issuer URL should look something like the following, and return a valid json document.
|
The Issuer URL should look something like the following, and return a valid json document.
|
||||||
@ -79,6 +80,10 @@ The Issuer URL should look something like the following, and return a valid json
|
|||||||
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
|
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Auto Launch
|
||||||
|
|
||||||
|
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
||||||
|
|
||||||
## Mobile Redirect URI
|
## Mobile Redirect URI
|
||||||
|
|
||||||
The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following:
|
The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following:
|
||||||
|
32
docs/docs/features/password-login.md
Normal file
32
docs/docs/features/password-login.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Password Login
|
||||||
|
|
||||||
|
An overview of password login and related settings for Immich.
|
||||||
|
|
||||||
|
## Enable/Disable
|
||||||
|
|
||||||
|
Immich supports password login, which is enabled by default. The preferred way to disable it is via the [Administration Page](#administration-page), although it can also be changed via a [Server Command](#server-command) as well.
|
||||||
|
|
||||||
|
### Administration Page
|
||||||
|
|
||||||
|
To toggle the password login setting via the web, navigate to the "Administration", expand "Password Authentication", toggle the "Enabled" switch, and press "Save".
|
||||||
|
|
||||||
|
![Password Login Settings](./img/password-login-settings.png)
|
||||||
|
|
||||||
|
### Server Command
|
||||||
|
|
||||||
|
There are two [Server Commands](/docs/features/server-commands.md) for password login:
|
||||||
|
|
||||||
|
1. `enable-password-login`
|
||||||
|
2. `disable-password-login`
|
||||||
|
|
||||||
|
See [Server Commands](/docs/features/server-commands.md) for more details about how to run them.
|
||||||
|
|
||||||
|
## Password Reset
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/features/server-commands.md).
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/features/user-management.mdx#password-reset) for more information about how to do this.
|
@ -1,21 +1,39 @@
|
|||||||
# Server Commands
|
# Server Commands
|
||||||
|
|
||||||
The `immich-server` docker image comes preinstalled with an administrative CLI that supports the following commands:
|
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich`) that supports the following commands:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ----------------------------- | ------------------------------------- |
|
| ------------------------ | ------------------------------------- |
|
||||||
| `immich help` | Display help |
|
| `help` | Display help |
|
||||||
| `immich reset-admin-password` | Reset the password for the admin user |
|
| `reset-admin-password` | Reset the password for the admin user |
|
||||||
|
| `disable-password-login` | Disable password login |
|
||||||
|
| `enable-password-login` | Enable password login |
|
||||||
|
|
||||||
## How to run a command
|
## How to run a command
|
||||||
|
|
||||||
To run a command, connect to the container and then execute it. For example:
|
To run a command, connect to the container and then execute it by running `immich <command>`.
|
||||||
|
|
||||||
```bash
|
## Examples
|
||||||
docker exec -it immich-server_1 sh
|
|
||||||
|
```bash title="Reset Admin Password"
|
||||||
|
docker exec -it immich_server sh
|
||||||
|
|
||||||
/usr/src/app$ immich reset-admin-password
|
/usr/src/app$ immich reset-admin-password
|
||||||
? Please choose a new password (optional) immich-is-awesome-unlike-this-password
|
? Please choose a new password (optional) immich-is-awesome-unlike-this-password
|
||||||
New password:
|
New password:
|
||||||
immich-is-awesome-unlike-this-password
|
immich-is-awesome-unlike-this-password
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash title="Disable Password Login"
|
||||||
|
docker exec -it immich_server sh
|
||||||
|
|
||||||
|
/usr/src/app$ immich disable-password-login
|
||||||
|
Password login has been disabled.
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash title="Enable Password Login"
|
||||||
|
docker exec -it immich_server sh
|
||||||
|
|
||||||
|
/usr/src/app$ immich enable-password-login
|
||||||
|
Password login has been enabled.
|
||||||
|
```
|
||||||
|
@ -16,3 +16,9 @@ Immich supports multiple users, each with their own library.
|
|||||||
## Delete a User
|
## Delete a User
|
||||||
|
|
||||||
If you need to remove a user from Immich, head to "Administration", where users can be scheduled for deletion. The user account will immediately become disabled and their library and all associated data will be removed after 7 days.
|
If you need to remove a user from Immich, head to "Administration", where users can be scheduled for deletion. The user account will immediately become disabled and their library and all associated data will be removed after 7 days.
|
||||||
|
|
||||||
|
## Password Reset
|
||||||
|
|
||||||
|
To reset a user's password, click the pencil icon to edit a user, then click "Reset Password". The user's password will be reset to "password" and they have to change it next time the sign in.
|
||||||
|
|
||||||
|
![Reset Password](./img/user-management-update.png)
|
||||||
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -76,6 +76,7 @@ doc/SystemConfigApi.md
|
|||||||
doc/SystemConfigDto.md
|
doc/SystemConfigDto.md
|
||||||
doc/SystemConfigFFmpegDto.md
|
doc/SystemConfigFFmpegDto.md
|
||||||
doc/SystemConfigOAuthDto.md
|
doc/SystemConfigOAuthDto.md
|
||||||
|
doc/SystemConfigPasswordLoginDto.md
|
||||||
doc/SystemConfigStorageTemplateDto.md
|
doc/SystemConfigStorageTemplateDto.md
|
||||||
doc/SystemConfigTemplateStorageOptionDto.md
|
doc/SystemConfigTemplateStorageOptionDto.md
|
||||||
doc/TagApi.md
|
doc/TagApi.md
|
||||||
@ -178,6 +179,7 @@ lib/model/smart_info_response_dto.dart
|
|||||||
lib/model/system_config_dto.dart
|
lib/model/system_config_dto.dart
|
||||||
lib/model/system_config_f_fmpeg_dto.dart
|
lib/model/system_config_f_fmpeg_dto.dart
|
||||||
lib/model/system_config_o_auth_dto.dart
|
lib/model/system_config_o_auth_dto.dart
|
||||||
|
lib/model/system_config_password_login_dto.dart
|
||||||
lib/model/system_config_storage_template_dto.dart
|
lib/model/system_config_storage_template_dto.dart
|
||||||
lib/model/system_config_template_storage_option_dto.dart
|
lib/model/system_config_template_storage_option_dto.dart
|
||||||
lib/model/tag_response_dto.dart
|
lib/model/tag_response_dto.dart
|
||||||
@ -267,6 +269,7 @@ test/system_config_api_test.dart
|
|||||||
test/system_config_dto_test.dart
|
test/system_config_dto_test.dart
|
||||||
test/system_config_f_fmpeg_dto_test.dart
|
test/system_config_f_fmpeg_dto_test.dart
|
||||||
test/system_config_o_auth_dto_test.dart
|
test/system_config_o_auth_dto_test.dart
|
||||||
|
test/system_config_password_login_dto_test.dart
|
||||||
test/system_config_storage_template_dto_test.dart
|
test/system_config_storage_template_dto_test.dart
|
||||||
test/system_config_template_storage_option_dto_test.dart
|
test/system_config_template_storage_option_dto_test.dart
|
||||||
test/tag_api_test.dart
|
test/tag_api_test.dart
|
||||||
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/OAuthConfigResponseDto.md
generated
BIN
mobile/openapi/doc/OAuthConfigResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SharedLinkResponseDto.md
generated
BIN
mobile/openapi/doc/SharedLinkResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigOAuthDto.md
generated
BIN
mobile/openapi/doc/SystemConfigOAuthDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigPasswordLoginDto.md
generated
Normal file
BIN
mobile/openapi/doc/SystemConfigPasswordLoginDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/o_auth_config_response_dto.dart
generated
BIN
mobile/openapi/lib/model/o_auth_config_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/shared_link_response_dto.dart
generated
BIN
mobile/openapi/lib/model/shared_link_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_o_auth_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_o_auth_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_password_login_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_password_login_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/o_auth_config_response_dto_test.dart
generated
BIN
mobile/openapi/test/o_auth_config_response_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/shared_link_response_dto_test.dart
generated
BIN
mobile/openapi/test/shared_link_response_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_o_auth_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_o_auth_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_password_login_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/system_config_password_login_dto_test.dart
generated
Normal file
Binary file not shown.
@ -1,10 +1,16 @@
|
|||||||
import { DatabaseModule, UserEntity } from '@app/database';
|
import { DatabaseModule, SystemConfigEntity, UserEntity } from '@app/database';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './commands/password-login';
|
||||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command';
|
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, TypeOrmModule.forFeature([UserEntity])],
|
imports: [DatabaseModule, TypeOrmModule.forFeature([UserEntity, SystemConfigEntity])],
|
||||||
providers: [ResetAdminPasswordCommand, PromptPasswordQuestions],
|
providers: [
|
||||||
|
ResetAdminPasswordCommand,
|
||||||
|
PromptPasswordQuestions,
|
||||||
|
EnablePasswordLoginCommand,
|
||||||
|
DisablePasswordLoginCommand,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
39
server/apps/cli/src/commands/password-login.ts
Normal file
39
server/apps/cli/src/commands/password-login.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { SystemConfigEntity, SystemConfigKey } from '@app/database';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Command, CommandRunner } from 'nest-commander';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
@Command({
|
||||||
|
name: 'enable-password-login',
|
||||||
|
description: 'Enable password login',
|
||||||
|
})
|
||||||
|
export class EnablePasswordLoginCommand extends CommandRunner {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SystemConfigEntity) private repository: Repository<SystemConfigEntity>, //
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.repository.delete({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED });
|
||||||
|
await axios.post('http://localhost:3001/refresh-config');
|
||||||
|
console.log('Password login has been enabled.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command({
|
||||||
|
name: 'disable-password-login',
|
||||||
|
description: 'Disable password login',
|
||||||
|
})
|
||||||
|
export class DisablePasswordLoginCommand extends CommandRunner {
|
||||||
|
constructor(@InjectRepository(SystemConfigEntity) private repository: Repository<SystemConfigEntity>) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.repository.save({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false });
|
||||||
|
await axios.post('http://localhost:3001/refresh-config');
|
||||||
|
console.log('Password login has been disabled.');
|
||||||
|
}
|
||||||
|
}
|
@ -136,6 +136,8 @@ describe('Album service', () => {
|
|||||||
getById: jest.fn(),
|
getById: jest.fn(),
|
||||||
getByKey: jest.fn(),
|
getByKey: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
hasAssetAccess: jest.fn(),
|
||||||
|
getByIdAndUserId: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadServiceMock = {
|
downloadServiceMock = {
|
||||||
|
@ -130,7 +130,6 @@ describe('AssetService', () => {
|
|||||||
getAssetWithNoSmartInfo: jest.fn(),
|
getAssetWithNoSmartInfo: jest.fn(),
|
||||||
getExistingAssets: jest.fn(),
|
getExistingAssets: jest.fn(),
|
||||||
countByIdAndUser: jest.fn(),
|
countByIdAndUser: jest.fn(),
|
||||||
getSharePermission: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadServiceMock = {
|
downloadServiceMock = {
|
||||||
@ -144,6 +143,8 @@ describe('AssetService', () => {
|
|||||||
getByKey: jest.fn(),
|
getByKey: jest.fn(),
|
||||||
remove: jest.fn(),
|
remove: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
hasAssetAccess: jest.fn(),
|
||||||
|
getByIdAndUserId: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sui = new AssetService(
|
sui = new AssetService(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ImmichConfigModule } from '@app/immich-config';
|
||||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||||
import { OAuthModule } from '../oauth/oauth.module';
|
import { OAuthModule } from '../oauth/oauth.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
@ -6,7 +7,7 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UserModule, ImmichJwtModule, OAuthModule],
|
imports: [UserModule, ImmichJwtModule, OAuthModule, ImmichConfigModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService],
|
providers: [AuthService],
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { UserEntity } from '@app/database';
|
import { UserEntity } from '@app/database';
|
||||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { SystemConfig } from '@app/database/entities/system-config.entity';
|
||||||
|
import { ImmichConfigService } from '@app/immich-config';
|
||||||
import { AuthType } from '../../constants/jwt.constant';
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
import { OAuthService } from '../oauth/oauth.service';
|
import { OAuthService } from '../oauth/oauth.service';
|
||||||
@ -16,6 +18,19 @@ const fixtures = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
enabled: {
|
||||||
|
passwordLogin: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
} as SystemConfig,
|
||||||
|
disabled: {
|
||||||
|
passwordLogin: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
} as SystemConfig,
|
||||||
|
};
|
||||||
|
|
||||||
const CLIENT_IP = '127.0.0.1';
|
const CLIENT_IP = '127.0.0.1';
|
||||||
|
|
||||||
jest.mock('bcrypt');
|
jest.mock('bcrypt');
|
||||||
@ -35,6 +50,7 @@ describe('AuthService', () => {
|
|||||||
let sut: AuthService;
|
let sut: AuthService;
|
||||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||||
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
|
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
|
||||||
|
let immichConfigServiceMock: jest.Mocked<ImmichConfigService>;
|
||||||
let oauthServiceMock: jest.Mocked<OAuthService>;
|
let oauthServiceMock: jest.Mocked<OAuthService>;
|
||||||
let compare: jest.Mock;
|
let compare: jest.Mock;
|
||||||
|
|
||||||
@ -71,14 +87,40 @@ describe('AuthService', () => {
|
|||||||
getLogoutEndpoint: jest.fn(),
|
getLogoutEndpoint: jest.fn(),
|
||||||
} as unknown as jest.Mocked<OAuthService>;
|
} as unknown as jest.Mocked<OAuthService>;
|
||||||
|
|
||||||
sut = new AuthService(oauthServiceMock, immichJwtServiceMock, userRepositoryMock);
|
immichConfigServiceMock = {
|
||||||
|
config$: { subscribe: jest.fn() },
|
||||||
|
} as unknown as jest.Mocked<ImmichConfigService>;
|
||||||
|
|
||||||
|
sut = new AuthService(
|
||||||
|
oauthServiceMock,
|
||||||
|
immichJwtServiceMock,
|
||||||
|
userRepositoryMock,
|
||||||
|
immichConfigServiceMock,
|
||||||
|
config.enabled,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(sut).toBeDefined();
|
expect(sut).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should subscribe to config changes', async () => {
|
||||||
|
expect(immichConfigServiceMock.config$.subscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
|
it('should throw an error if password login is disabled', async () => {
|
||||||
|
sut = new AuthService(
|
||||||
|
oauthServiceMock,
|
||||||
|
immichJwtServiceMock,
|
||||||
|
userRepositoryMock,
|
||||||
|
immichConfigServiceMock,
|
||||||
|
config.disabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
it('should check the user exists', async () => {
|
it('should check the user exists', async () => {
|
||||||
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||||
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
@ -170,7 +212,7 @@ describe('AuthService', () => {
|
|||||||
it('should return the default redirect', async () => {
|
it('should return the default redirect', async () => {
|
||||||
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
|
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
|
||||||
successful: true,
|
successful: true,
|
||||||
redirectUri: '/auth/login',
|
redirectUri: '/auth/login?autoLaunch=0',
|
||||||
});
|
});
|
||||||
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
|
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@ -20,6 +20,8 @@ import { LoginResponseDto } from './response-dto/login-response.dto';
|
|||||||
import { LogoutResponseDto } from './response-dto/logout-response.dto';
|
import { LogoutResponseDto } from './response-dto/logout-response.dto';
|
||||||
import { OAuthService } from '../oauth/oauth.service';
|
import { OAuthService } from '../oauth/oauth.service';
|
||||||
import { UserCore } from '../user/user.core';
|
import { UserCore } from '../user/user.core';
|
||||||
|
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
|
||||||
|
import { SystemConfig } from '@app/database/entities/system-config.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -30,11 +32,18 @@ export class AuthService {
|
|||||||
private oauthService: OAuthService,
|
private oauthService: OAuthService,
|
||||||
private immichJwtService: ImmichJwtService,
|
private immichJwtService: ImmichJwtService,
|
||||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||||
|
private configService: ImmichConfigService,
|
||||||
|
@Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
|
||||||
) {
|
) {
|
||||||
this.userCore = new UserCore(userRepository);
|
this.userCore = new UserCore(userRepository);
|
||||||
|
this.configService.config$.subscribe((config) => (this.config = config));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
|
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
|
||||||
|
if (!this.config.passwordLogin.enabled) {
|
||||||
|
throw new UnauthorizedException('Password login has been disabled');
|
||||||
|
}
|
||||||
|
|
||||||
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@ -60,7 +69,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { successful: true, redirectUri: '/auth/login' };
|
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
|
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
|
||||||
|
@ -17,29 +17,37 @@ const config = {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
buttonText: 'OAuth',
|
buttonText: 'OAuth',
|
||||||
issuerUrl: 'http://issuer,',
|
issuerUrl: 'http://issuer,',
|
||||||
|
autoLaunch: false,
|
||||||
},
|
},
|
||||||
|
passwordLogin: { enabled: true },
|
||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
enabled: {
|
enabled: {
|
||||||
oauth: {
|
oauth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoRegister: true,
|
autoRegister: true,
|
||||||
buttonText: 'OAuth',
|
buttonText: 'OAuth',
|
||||||
|
autoLaunch: false,
|
||||||
},
|
},
|
||||||
|
passwordLogin: { enabled: true },
|
||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
noAutoRegister: {
|
noAutoRegister: {
|
||||||
oauth: {
|
oauth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoRegister: false,
|
autoRegister: false,
|
||||||
|
autoLaunch: false,
|
||||||
},
|
},
|
||||||
|
passwordLogin: { enabled: true },
|
||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
override: {
|
override: {
|
||||||
oauth: {
|
oauth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoRegister: true,
|
autoRegister: true,
|
||||||
|
autoLaunch: false,
|
||||||
buttonText: 'OAuth',
|
buttonText: 'OAuth',
|
||||||
mobileOverrideEnabled: true,
|
mobileOverrideEnabled: true,
|
||||||
mobileRedirectUri: 'http://mobile-redirect',
|
mobileRedirectUri: 'http://mobile-redirect',
|
||||||
},
|
},
|
||||||
|
passwordLogin: { enabled: true },
|
||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,7 +132,6 @@ describe('OAuthService', () => {
|
|||||||
|
|
||||||
immichConfigServiceMock = {
|
immichConfigServiceMock = {
|
||||||
config$: { subscribe: jest.fn() },
|
config$: { subscribe: jest.fn() },
|
||||||
getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }),
|
|
||||||
} as unknown as jest.Mocked<ImmichConfigService>;
|
} as unknown as jest.Mocked<ImmichConfigService>;
|
||||||
|
|
||||||
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled);
|
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled);
|
||||||
@ -136,7 +143,10 @@ describe('OAuthService', () => {
|
|||||||
|
|
||||||
describe('generateConfig', () => {
|
describe('generateConfig', () => {
|
||||||
it('should work when oauth is not configured', async () => {
|
it('should work when oauth is not configured', async () => {
|
||||||
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
|
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
|
||||||
|
enabled: false,
|
||||||
|
passwordLoginEnabled: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate the config', async () => {
|
it('should generate the config', async () => {
|
||||||
@ -145,6 +155,8 @@ describe('OAuthService', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
buttonText: 'OAuth',
|
buttonText: 'OAuth',
|
||||||
url: 'http://authorization-url',
|
url: 'http://authorization-url',
|
||||||
|
autoLaunch: false,
|
||||||
|
passwordLoginEnabled: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -39,19 +39,24 @@ export class OAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||||
const { enabled, scope, buttonText } = this.config.oauth;
|
const response = {
|
||||||
const redirectUri = this.normalize(dto.redirectUri);
|
enabled: this.config.oauth.enabled,
|
||||||
|
passwordLoginEnabled: this.config.passwordLogin.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
if (!enabled) {
|
if (!response.enabled) {
|
||||||
return { enabled: false };
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { scope, buttonText, autoLaunch } = this.config.oauth;
|
||||||
|
const redirectUri = this.normalize(dto.redirectUri);
|
||||||
const url = (await this.getClient()).authorizationUrl({
|
const url = (await this.getClient()).authorizationUrl({
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope,
|
scope,
|
||||||
state: generators.state(),
|
state: generators.state(),
|
||||||
});
|
});
|
||||||
return { enabled: true, buttonText, url };
|
|
||||||
|
return { ...response, buttonText, url, autoLaunch };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
|
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
|
||||||
|
@ -1,12 +1,7 @@
|
|||||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class OAuthConfigResponseDto {
|
export class OAuthConfigResponseDto {
|
||||||
@ApiResponseProperty()
|
|
||||||
enabled!: boolean;
|
enabled!: boolean;
|
||||||
|
passwordLoginEnabled!: boolean;
|
||||||
@ApiResponseProperty()
|
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
||||||
@ApiResponseProperty()
|
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
autoLaunch?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,9 @@ export class SystemConfigOAuthDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
autoRegister!: boolean;
|
autoRegister!: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
autoLaunch!: boolean;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
mobileOverrideEnabled!: boolean;
|
mobileOverrideEnabled!: boolean;
|
||||||
|
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
import { IsBoolean } from 'class-validator';
|
||||||
|
|
||||||
|
export class SystemConfigPasswordLoginDto {
|
||||||
|
@IsBoolean()
|
||||||
|
enabled!: boolean;
|
||||||
|
}
|
@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database';
|
|||||||
import { ValidateNested } from 'class-validator';
|
import { ValidateNested } from 'class-validator';
|
||||||
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
|
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
|
||||||
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
|
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
|
||||||
|
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
|
||||||
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
|
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
|
||||||
|
|
||||||
export class SystemConfigDto {
|
export class SystemConfigDto {
|
||||||
@ -11,6 +12,9 @@ export class SystemConfigDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
oauth!: SystemConfigOAuthDto;
|
oauth!: SystemConfigOAuthDto;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
passwordLogin!: SystemConfigPasswordLoginDto;
|
||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
storageTemplate!: SystemConfigStorageTemplateDto;
|
storageTemplate!: SystemConfigStorageTemplateDto;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,15 @@
|
|||||||
import { Controller } from '@nestjs/common';
|
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
|
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||||
|
import { ImmichConfigService } from '@app/immich-config';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {}
|
export class AppController {
|
||||||
|
constructor(private configService: ImmichConfigService) {}
|
||||||
|
|
||||||
|
@ApiExcludeEndpoint()
|
||||||
|
@Post('refresh-config')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
public reloadConfig() {
|
||||||
|
return this.configService.refreshConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
|||||||
import { UserModule } from './api-v1/user/user.module';
|
import { UserModule } from './api-v1/user/user.module';
|
||||||
import { AssetModule } from './api-v1/asset/asset.module';
|
import { AssetModule } from './api-v1/asset/asset.module';
|
||||||
import { AuthModule } from './api-v1/auth/auth.module';
|
import { AuthModule } from './api-v1/auth/auth.module';
|
||||||
|
import { APIKeyModule } from './api-v1/api-key/api-key.module';
|
||||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
@ -19,8 +20,8 @@ import { JobModule } from './api-v1/job/job.module';
|
|||||||
import { SystemConfigModule } from './api-v1/system-config/system-config.module';
|
import { SystemConfigModule } from './api-v1/system-config/system-config.module';
|
||||||
import { OAuthModule } from './api-v1/oauth/oauth.module';
|
import { OAuthModule } from './api-v1/oauth/oauth.module';
|
||||||
import { TagModule } from './api-v1/tag/tag.module';
|
import { TagModule } from './api-v1/tag/tag.module';
|
||||||
|
import { ImmichConfigModule } from '@app/immich-config';
|
||||||
import { ShareModule } from './api-v1/share/share.module';
|
import { ShareModule } from './api-v1/share/share.module';
|
||||||
import { APIKeyModule } from './api-v1/api-key/api-key.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -37,6 +38,7 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module';
|
|||||||
OAuthModule,
|
OAuthModule,
|
||||||
|
|
||||||
ImmichJwtModule,
|
ImmichJwtModule,
|
||||||
|
ImmichConfigModule,
|
||||||
|
|
||||||
DeviceInfoModule,
|
DeviceInfoModule,
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
|
|
||||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||||
import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
|
import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
|
||||||
|
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
|
||||||
|
|
||||||
export const API_KEY_STRATEGY = 'api-key';
|
export const API_KEY_STRATEGY = 'api-key';
|
||||||
|
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
|
import { UserEntity } from '@app/database';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
|
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
|
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
|
||||||
import { UserEntity } from '@app/database';
|
|
||||||
import { jwtSecret } from '../../../constants/jwt.constant';
|
import { jwtSecret } from '../../../constants/jwt.constant';
|
||||||
|
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
|
||||||
import { ImmichJwtService } from '../immich-jwt.service';
|
import { ImmichJwtService } from '../immich-jwt.service';
|
||||||
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
|
|
||||||
|
|
||||||
export const JWT_STRATEGY = 'jwt';
|
export const JWT_STRATEGY = 'jwt';
|
||||||
|
|
||||||
|
@ -2,10 +2,10 @@ import { UserEntity } from '@app/database';
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { ShareService } from '../../../api-v1/share/share.service';
|
|
||||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
|
import { ShareService } from '../../../api-v1/share/share.service';
|
||||||
|
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
|
||||||
|
|
||||||
export const PUBLIC_SHARE_STRATEGY = 'public-share';
|
export const PUBLIC_SHARE_STRATEGY = 'public-share';
|
||||||
|
|
||||||
|
@ -3936,20 +3936,24 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean"
|
||||||
"readOnly": true
|
},
|
||||||
|
"passwordLoginEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"readOnly": true
|
|
||||||
},
|
},
|
||||||
"buttonText": {
|
"buttonText": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"readOnly": true
|
},
|
||||||
|
"autoLaunch": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"enabled"
|
"enabled",
|
||||||
|
"passwordLoginEnabled"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"OAuthCallbackDto": {
|
"OAuthCallbackDto": {
|
||||||
@ -4334,6 +4338,9 @@
|
|||||||
"autoRegister": {
|
"autoRegister": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"autoLaunch": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"mobileOverrideEnabled": {
|
"mobileOverrideEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@ -4349,10 +4356,22 @@
|
|||||||
"scope",
|
"scope",
|
||||||
"buttonText",
|
"buttonText",
|
||||||
"autoRegister",
|
"autoRegister",
|
||||||
|
"autoLaunch",
|
||||||
"mobileOverrideEnabled",
|
"mobileOverrideEnabled",
|
||||||
"mobileRedirectUri"
|
"mobileRedirectUri"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SystemConfigPasswordLoginDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SystemConfigStorageTemplateDto": {
|
"SystemConfigStorageTemplateDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -4373,6 +4392,9 @@
|
|||||||
"oauth": {
|
"oauth": {
|
||||||
"$ref": "#/components/schemas/SystemConfigOAuthDto"
|
"$ref": "#/components/schemas/SystemConfigOAuthDto"
|
||||||
},
|
},
|
||||||
|
"passwordLogin": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigPasswordLoginDto"
|
||||||
|
},
|
||||||
"storageTemplate": {
|
"storageTemplate": {
|
||||||
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
|
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
|
||||||
}
|
}
|
||||||
@ -4380,6 +4402,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
"passwordLogin",
|
||||||
"storageTemplate"
|
"storageTemplate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity('system_config')
|
@Entity('system_config')
|
||||||
export class SystemConfigEntity<T = string> {
|
export class SystemConfigEntity<T = string | boolean> {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
key!: SystemConfigKey;
|
key!: SystemConfigKey;
|
||||||
|
|
||||||
@ -23,10 +23,12 @@ export enum SystemConfigKey {
|
|||||||
OAUTH_CLIENT_ID = 'oauth.clientId',
|
OAUTH_CLIENT_ID = 'oauth.clientId',
|
||||||
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
|
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
|
||||||
OAUTH_SCOPE = 'oauth.scope',
|
OAUTH_SCOPE = 'oauth.scope',
|
||||||
|
OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch',
|
||||||
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
|
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
|
||||||
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
|
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
|
||||||
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
|
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
|
||||||
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
|
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
|
||||||
|
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
|
||||||
STORAGE_TEMPLATE = 'storageTemplate.template',
|
STORAGE_TEMPLATE = 'storageTemplate.template',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,9 +48,13 @@ export interface SystemConfig {
|
|||||||
scope: string;
|
scope: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
autoRegister: boolean;
|
autoRegister: boolean;
|
||||||
|
autoLaunch: boolean;
|
||||||
mobileOverrideEnabled: boolean;
|
mobileOverrideEnabled: boolean;
|
||||||
mobileRedirectUri: string;
|
mobileRedirectUri: string;
|
||||||
};
|
};
|
||||||
|
passwordLogin: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
storageTemplate: {
|
storageTemplate: {
|
||||||
template: string;
|
template: string;
|
||||||
};
|
};
|
||||||
|
@ -25,6 +25,10 @@ const defaults: SystemConfig = Object.freeze({
|
|||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
buttonText: 'Login with OAuth',
|
buttonText: 'Login with OAuth',
|
||||||
autoRegister: true,
|
autoRegister: true,
|
||||||
|
autoLaunch: false,
|
||||||
|
},
|
||||||
|
passwordLogin: {
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
storageTemplate: {
|
storageTemplate: {
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"package": "svelte-kit package",
|
"package": "svelte-kit package",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
"check": "svelte-check --no-tsconfig --fail-on-warnings --ignore \"src/api/open-api\"",
|
||||||
"check:watch": "npm run check -- --watch",
|
"check:watch": "npm run check -- --watch",
|
||||||
"check:code": "npm run format && npm run lint && npm run check",
|
"check:code": "npm run format && npm run lint && npm run check",
|
||||||
"check:all": "npm run check:code && npm test",
|
"check:all": "npm run check:code && npm test",
|
||||||
|
41
web/src/api/open-api/api.ts
generated
41
web/src/api/open-api/api.ts
generated
@ -1377,6 +1377,12 @@ export interface OAuthConfigResponseDto {
|
|||||||
* @memberof OAuthConfigResponseDto
|
* @memberof OAuthConfigResponseDto
|
||||||
*/
|
*/
|
||||||
'enabled': boolean;
|
'enabled': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof OAuthConfigResponseDto
|
||||||
|
*/
|
||||||
|
'passwordLoginEnabled': boolean;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
@ -1389,6 +1395,12 @@ export interface OAuthConfigResponseDto {
|
|||||||
* @memberof OAuthConfigResponseDto
|
* @memberof OAuthConfigResponseDto
|
||||||
*/
|
*/
|
||||||
'buttonText'?: string;
|
'buttonText'?: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof OAuthConfigResponseDto
|
||||||
|
*/
|
||||||
|
'autoLaunch'?: boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -1602,10 +1614,10 @@ export interface SharedLinkResponseDto {
|
|||||||
'expiresAt': string | null;
|
'expiresAt': string | null;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {Array<string>}
|
* @type {Array<AssetResponseDto>}
|
||||||
* @memberof SharedLinkResponseDto
|
* @memberof SharedLinkResponseDto
|
||||||
*/
|
*/
|
||||||
'assets': Array<string>;
|
'assets': Array<AssetResponseDto>;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {AlbumResponseDto}
|
* @type {AlbumResponseDto}
|
||||||
@ -1707,6 +1719,12 @@ export interface SystemConfigDto {
|
|||||||
* @memberof SystemConfigDto
|
* @memberof SystemConfigDto
|
||||||
*/
|
*/
|
||||||
'oauth': SystemConfigOAuthDto;
|
'oauth': SystemConfigOAuthDto;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {SystemConfigPasswordLoginDto}
|
||||||
|
* @memberof SystemConfigDto
|
||||||
|
*/
|
||||||
|
'passwordLogin': SystemConfigPasswordLoginDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {SystemConfigStorageTemplateDto}
|
* @type {SystemConfigStorageTemplateDto}
|
||||||
@ -1799,6 +1817,12 @@ export interface SystemConfigOAuthDto {
|
|||||||
* @memberof SystemConfigOAuthDto
|
* @memberof SystemConfigOAuthDto
|
||||||
*/
|
*/
|
||||||
'autoRegister': boolean;
|
'autoRegister': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigOAuthDto
|
||||||
|
*/
|
||||||
|
'autoLaunch': boolean;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
@ -1812,6 +1836,19 @@ export interface SystemConfigOAuthDto {
|
|||||||
*/
|
*/
|
||||||
'mobileRedirectUri': string;
|
'mobileRedirectUri': string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface SystemConfigPasswordLoginDto
|
||||||
|
*/
|
||||||
|
export interface SystemConfigPasswordLoginDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigPasswordLoginDto
|
||||||
|
*/
|
||||||
|
'enabled': boolean;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
|
@ -22,6 +22,15 @@ export const oauth = {
|
|||||||
const search = location.search;
|
const search = location.search;
|
||||||
return search.includes('code=') || search.includes('error=');
|
return search.includes('code=') || search.includes('error=');
|
||||||
},
|
},
|
||||||
|
isAutoLaunchDisabled: (location: Location) => {
|
||||||
|
const values = ['autoLaunch=0', 'password=1', 'password=true'];
|
||||||
|
for (const value of values) {
|
||||||
|
if (location.search.includes(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
getConfig: (location: Location) => {
|
getConfig: (location: Location) => {
|
||||||
const redirectUri = location.href.split('?')[0];
|
const redirectUri = location.href.split('?')[0];
|
||||||
console.log(`OAuth Redirect URI: ${redirectUri}`);
|
console.log(`OAuth Redirect URI: ${redirectUri}`);
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ConfirmDialogue title="Disable Login" on:cancel on:confirm>
|
||||||
|
<svelte:fragment slot="prompt">
|
||||||
|
<div class="flex flex-col gap-4 p-3">
|
||||||
|
<p class="text-md text-center">
|
||||||
|
Are you sure you want to disable all login methods? Login will be completely disabled.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-md text-center">
|
||||||
|
To re-enable, use a
|
||||||
|
<a
|
||||||
|
href="https://immich.app/docs/features/server-commands"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
class="underline"
|
||||||
|
>
|
||||||
|
Server Command</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</svelte:fragment>
|
||||||
|
</ConfirmDialogue>
|
@ -7,6 +7,7 @@
|
|||||||
import { api, SystemConfigOAuthDto } from '@api';
|
import { api, SystemConfigOAuthDto } from '@api';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
|
||||||
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
||||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||||
import SettingSwitch from '../setting-switch.svelte';
|
import SettingSwitch from '../setting-switch.svelte';
|
||||||
@ -43,26 +44,43 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isConfirmOpen = false;
|
||||||
|
let handleConfirm: (value: boolean) => void;
|
||||||
|
|
||||||
|
const openConfirmModal = () => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
handleConfirm = (value: boolean) => {
|
||||||
|
isConfirmOpen = false;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
isConfirmOpen = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
async function saveSetting() {
|
async function saveSetting() {
|
||||||
try {
|
try {
|
||||||
const { data: currentConfig } = await api.systemConfigApi.getConfig();
|
const { data: current } = await api.systemConfigApi.getConfig();
|
||||||
|
|
||||||
|
if (!current.passwordLogin.enabled && current.oauth.enabled && !oauthConfig.enabled) {
|
||||||
|
const confirmed = await openConfirmModal();
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!oauthConfig.mobileOverrideEnabled) {
|
if (!oauthConfig.mobileOverrideEnabled) {
|
||||||
oauthConfig.mobileRedirectUri = '';
|
oauthConfig.mobileRedirectUri = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.systemConfigApi.updateConfig({
|
const { data: updated } = await api.systemConfigApi.updateConfig({
|
||||||
...currentConfig,
|
...current,
|
||||||
oauth: oauthConfig
|
oauth: oauthConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
oauthConfig = { ...result.data.oauth };
|
oauthConfig = { ...updated.oauth };
|
||||||
savedConfig = { ...result.data.oauth };
|
savedConfig = { ...updated.oauth };
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({ message: 'OAuth settings saved', type: NotificationType.Info });
|
||||||
message: 'OAuth settings saved',
|
|
||||||
type: NotificationType.Info
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Unable to save OAuth settings');
|
handleError(error, 'Unable to save OAuth settings');
|
||||||
}
|
}
|
||||||
@ -80,6 +98,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if isConfirmOpen}
|
||||||
|
<ConfirmDisableLogin
|
||||||
|
on:cancel={() => handleConfirm(false)}
|
||||||
|
on:confirm={() => handleConfirm(true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{#await getConfigs() then}
|
{#await getConfigs() then}
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
@ -147,6 +172,13 @@
|
|||||||
disabled={!oauthConfig.enabled}
|
disabled={!oauthConfig.enabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title="AUTO LAUNCH"
|
||||||
|
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
|
||||||
|
disabled={!oauthConfig.enabled}
|
||||||
|
bind:checked={oauthConfig.autoLaunch}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title="MOBILE REDIRECT URI OVERRIDE"
|
title="MOBILE REDIRECT URI OVERRIDE"
|
||||||
subtitle="Enable when `app.immich:/` is an invalid redirect URI."
|
subtitle="Enable when `app.immich:/` is an invalid redirect URI."
|
||||||
|
@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { api, SystemConfigPasswordLoginDto } from '@api';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
|
||||||
|
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
||||||
|
import SettingSwitch from '../setting-switch.svelte';
|
||||||
|
|
||||||
|
export let passwordLoginConfig: SystemConfigPasswordLoginDto; // this is the config that is being edited
|
||||||
|
|
||||||
|
let savedConfig: SystemConfigPasswordLoginDto;
|
||||||
|
let defaultConfig: SystemConfigPasswordLoginDto;
|
||||||
|
|
||||||
|
async function getConfigs() {
|
||||||
|
[savedConfig, defaultConfig] = await Promise.all([
|
||||||
|
api.systemConfigApi.getConfig().then((res) => res.data.passwordLogin),
|
||||||
|
api.systemConfigApi.getDefaults().then((res) => res.data.passwordLogin)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isConfirmOpen = false;
|
||||||
|
let handleConfirm: (value: boolean) => void;
|
||||||
|
|
||||||
|
const openConfirmModal = () => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
handleConfirm = (value: boolean) => {
|
||||||
|
isConfirmOpen = false;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
isConfirmOpen = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function saveSetting() {
|
||||||
|
try {
|
||||||
|
const { data: current } = await api.systemConfigApi.getConfig();
|
||||||
|
|
||||||
|
if (!current.oauth.enabled && current.passwordLogin.enabled && !passwordLoginConfig.enabled) {
|
||||||
|
const confirmed = await openConfirmModal();
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: updated } = await api.systemConfigApi.updateConfig({
|
||||||
|
...current,
|
||||||
|
passwordLogin: passwordLoginConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
passwordLoginConfig = { ...updated.passwordLogin };
|
||||||
|
savedConfig = { ...updated.passwordLogin };
|
||||||
|
|
||||||
|
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Unable to save settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
const { data: resetConfig } = await api.systemConfigApi.getConfig();
|
||||||
|
|
||||||
|
passwordLoginConfig = { ...resetConfig.passwordLogin };
|
||||||
|
savedConfig = { ...resetConfig.passwordLogin };
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Reset settings to the recent saved settings',
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetToDefault() {
|
||||||
|
const { data: configs } = await api.systemConfigApi.getDefaults();
|
||||||
|
|
||||||
|
passwordLoginConfig = { ...configs.passwordLogin };
|
||||||
|
defaultConfig = { ...configs.passwordLogin };
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Reset password settings to default',
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isConfirmOpen}
|
||||||
|
<ConfirmDisableLogin
|
||||||
|
on:cancel={() => handleConfirm(false)}
|
||||||
|
on:confirm={() => handleConfirm(true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#await getConfigs() then}
|
||||||
|
<div in:fade={{ duration: 500 }}>
|
||||||
|
<form autocomplete="off" on:submit|preventDefault>
|
||||||
|
<div class="flex flex-col gap-4 ml-4 mt-4">
|
||||||
|
<div class="ml-4">
|
||||||
|
<SettingSwitch
|
||||||
|
title="ENABLED"
|
||||||
|
subtitle="Login with email and password"
|
||||||
|
bind:checked={passwordLoginConfig.enabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingButtonsRow
|
||||||
|
on:reset={reset}
|
||||||
|
on:save={saveSetting}
|
||||||
|
on:reset-to-default={resetToDefault}
|
||||||
|
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
</div>
|
@ -93,7 +93,6 @@ describe('AlbumCard component', () => {
|
|||||||
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
|
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
|
||||||
'thumbnailIdOne',
|
'thumbnailIdOne',
|
||||||
ThumbnailFormat.Jpeg,
|
ThumbnailFormat.Jpeg,
|
||||||
'',
|
|
||||||
{ responseType: 'blob' }
|
{ responseType: 'blob' }
|
||||||
);
|
);
|
||||||
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);
|
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
export let variant: 'simple' | 'full' = 'full';
|
export let variant: 'simple' | 'full' = 'full';
|
||||||
export let searchQuery: string = '';
|
export let searchQuery = '';
|
||||||
let albumNameArray: string[] = ['', '', ''];
|
let albumNameArray: string[] = ['', '', ''];
|
||||||
|
|
||||||
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
|
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
|
||||||
|
@ -9,7 +9,6 @@
|
|||||||
export let publicSharedKey = '';
|
export let publicSharedKey = '';
|
||||||
let asset: AssetResponseDto;
|
let asset: AssetResponseDto;
|
||||||
|
|
||||||
let videoPlayerNode: HTMLVideoElement;
|
|
||||||
let isVideoLoading = true;
|
let isVideoLoading = true;
|
||||||
let videoUrl: string;
|
let videoUrl: string;
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
@ -55,7 +54,6 @@
|
|||||||
class="h-full object-contain"
|
class="h-full object-contain"
|
||||||
on:canplay={handleCanPlay}
|
on:canplay={handleCanPlay}
|
||||||
on:ended={() => dispatch('onVideoEnded')}
|
on:ended={() => dispatch('onVideoEnded')}
|
||||||
bind:this={videoPlayerNode}
|
|
||||||
>
|
>
|
||||||
<source src={videoUrl} type="video/mp4" />
|
<source src={videoUrl} type="video/mp4" />
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { loginPageMessage } from '$lib/constants';
|
import { loginPageMessage } from '$lib/constants';
|
||||||
import { api, oauth, OAuthConfigResponseDto } from '@api';
|
import { api, oauth, OAuthConfigResponseDto } from '@api';
|
||||||
@ -8,7 +9,7 @@
|
|||||||
let email = '';
|
let email = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let oauthError: string;
|
let oauthError: string;
|
||||||
let oauthConfig: OAuthConfigResponseDto = { enabled: false };
|
let oauthConfig: OAuthConfigResponseDto = { enabled: false, passwordLoginEnabled: false };
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
@ -30,6 +31,14 @@
|
|||||||
try {
|
try {
|
||||||
const { data } = await oauth.getConfig(window.location);
|
const { data } = await oauth.getConfig(window.location);
|
||||||
oauthConfig = data;
|
oauthConfig = data;
|
||||||
|
|
||||||
|
const { enabled, url, autoLaunch } = oauthConfig;
|
||||||
|
|
||||||
|
if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) {
|
||||||
|
await goto('/auth/login?autoLaunch=0', { replaceState: true });
|
||||||
|
await goto(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error [login-form] [oauth.generateConfig]', e);
|
console.error('Error [login-form] [oauth.generateConfig]', e);
|
||||||
}
|
}
|
||||||
@ -83,6 +92,7 @@
|
|||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if oauthConfig.passwordLoginEnabled}
|
||||||
<form on:submit|preventDefault={login} autocomplete="off">
|
<form on:submit|preventDefault={login} autocomplete="off">
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="email">Email</label>
|
<label class="immich-form-label" for="email">Email</label>
|
||||||
@ -120,10 +130,14 @@
|
|||||||
>Login</button
|
>Login</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if oauthConfig.enabled}
|
{#if oauthConfig.enabled}
|
||||||
<div class="flex flex-col gap-4 px-4">
|
<div class="flex flex-col gap-4 px-4">
|
||||||
|
{#if oauthConfig.passwordLoginEnabled}
|
||||||
<hr />
|
<hr />
|
||||||
|
{/if}
|
||||||
{#if oauthError}
|
{#if oauthError}
|
||||||
<p class="text-red-400">{oauthError}</p>
|
<p class="text-red-400">{oauthError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@ -137,6 +151,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
|
||||||
|
{#if !oauthConfig.enabled && !oauthConfig.passwordLoginEnabled}
|
||||||
|
<p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import FullScreenModal from './full-screen-modal.svelte';
|
import FullScreenModal from './full-screen-modal.svelte';
|
||||||
|
|
||||||
export let title = 'Confirm Delete';
|
export let title = 'Confirm';
|
||||||
export let prompt = 'Are you sure you want to delete this item?';
|
export let prompt = 'Are you sure you want to do this?';
|
||||||
export let confirmText = 'Confirm';
|
export let confirmText = 'Confirm';
|
||||||
export let cancelText = 'Cancel';
|
export let cancelText = 'Cancel';
|
||||||
|
|
||||||
@ -19,12 +19,14 @@
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
>
|
>
|
||||||
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
|
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium pb-2">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<slot name="prompt">
|
||||||
<p class="ml-4 text-md py-5 text-center">{prompt}</p>
|
<p class="ml-4 text-md py-5 text-center">{prompt}</p>
|
||||||
|
</slot>
|
||||||
|
|
||||||
<div class="flex w-full px-4 gap-4 mt-4">
|
<div class="flex w-full px-4 gap-4 mt-4">
|
||||||
<button
|
<button
|
@ -15,7 +15,6 @@
|
|||||||
export let album: AlbumResponseDto | undefined;
|
export let album: AlbumResponseDto | undefined;
|
||||||
export let editingLink: SharedLinkResponseDto | undefined = undefined;
|
export let editingLink: SharedLinkResponseDto | undefined = undefined;
|
||||||
|
|
||||||
let isLoading = false;
|
|
||||||
let isShowSharedLink = false;
|
let isShowSharedLink = false;
|
||||||
let expirationTime = '';
|
let expirationTime = '';
|
||||||
let isAllowUpload = false;
|
let isAllowUpload = false;
|
||||||
@ -40,7 +39,6 @@
|
|||||||
|
|
||||||
const createAlbumSharedLink = async () => {
|
const createAlbumSharedLink = async () => {
|
||||||
if (album) {
|
if (album) {
|
||||||
isLoading = true;
|
|
||||||
try {
|
try {
|
||||||
const expirationTime = getExpirationTimeInMillisecond();
|
const expirationTime = getExpirationTimeInMillisecond();
|
||||||
const currentTime = new Date().getTime();
|
const currentTime = new Date().getTime();
|
||||||
@ -56,7 +54,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
buildSharedLink(data);
|
buildSharedLink(data);
|
||||||
isLoading = false;
|
|
||||||
isShowSharedLink = true;
|
isShowSharedLink = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[createAlbumSharedLink] Error: ', e);
|
console.error('[createAlbumSharedLink] Error: ', e);
|
||||||
@ -64,7 +61,6 @@
|
|||||||
type: NotificationType.Error,
|
type: NotificationType.Error,
|
||||||
message: 'Failed to create shared link'
|
message: 'Failed to create shared link'
|
||||||
});
|
});
|
||||||
isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -34,9 +34,9 @@
|
|||||||
const logOut = async () => {
|
const logOut = async () => {
|
||||||
const { data } = await api.authenticationApi.logout();
|
const { data } = await api.authenticationApi.logout();
|
||||||
|
|
||||||
await fetch('auth/logout', { method: 'POST' });
|
await fetch('/auth/logout', { method: 'POST' });
|
||||||
|
|
||||||
goto(data.redirectUri || '/auth/login');
|
goto(data.redirectUri || '/auth/login?autoLaunch=0');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -47,9 +47,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* DOM Element or CSS Selector
|
* DOM Element or CSS Selector
|
||||||
* @type { HTMLElement|string}
|
|
||||||
*/
|
*/
|
||||||
export let target = 'body';
|
export let target: HTMLElement | string = 'body';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div use:portal={target} hidden>
|
<div use:portal={target} hidden>
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
if (link.album?.albumThumbnailAssetId) {
|
if (link.album?.albumThumbnailAssetId) {
|
||||||
assetId = link.album.albumThumbnailAssetId;
|
assetId = link.album.albumThumbnailAssetId;
|
||||||
} else if (link.assets.length > 0) {
|
} else if (link.assets.length > 0) {
|
||||||
assetId = link.assets[0];
|
assetId = link.assets[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await api.assetApi.getAssetById(assetId);
|
const { data } = await api.assetApi.getAssetById(assetId);
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
let config: OAuthConfigResponseDto = { enabled: false };
|
let config: OAuthConfigResponseDto = { enabled: false, passwordLoginEnabled: true };
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
import { handleError } from '../../utils/handle-error';
|
import { handleError } from '../../utils/handle-error';
|
||||||
import APIKeyForm from '../forms/api-key-form.svelte';
|
import APIKeyForm from '../forms/api-key-form.svelte';
|
||||||
import APIKeySecret from '../forms/api-key-secret.svelte';
|
import APIKeySecret from '../forms/api-key-secret.svelte';
|
||||||
import DeleteConfirmDialogue from '../shared-components/delete-confirm-dialogue.svelte';
|
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
|
||||||
import {
|
import {
|
||||||
notificationController,
|
notificationController,
|
||||||
NotificationType
|
NotificationType
|
||||||
@ -114,7 +114,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if deleteKey}
|
{#if deleteKey}
|
||||||
<DeleteConfirmDialogue
|
<ConfirmDialogue
|
||||||
prompt="Are you sure you want to delete this API Key?"
|
prompt="Are you sure you want to delete this API Key?"
|
||||||
on:confirm={() => handleDelete()}
|
on:confirm={() => handleDelete()}
|
||||||
on:cancel={() => (deleteKey = null)}
|
on:cancel={() => (deleteKey = null)}
|
||||||
|
@ -4,7 +4,6 @@ import {
|
|||||||
NotificationType
|
NotificationType
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { downloadAssets } from '$lib/stores/download';
|
import { downloadAssets } from '$lib/stores/download';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
export const addAssetsToAlbum = async (
|
export const addAssetsToAlbum = async (
|
||||||
albumId: string,
|
albumId: string,
|
||||||
@ -34,7 +33,7 @@ export async function bulkDownload(
|
|||||||
const assetIds = assets.map((asset) => asset.id);
|
const assetIds = assets.map((asset) => asset.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let skip = 0;
|
// let skip = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|
||||||
@ -68,7 +67,7 @@ export async function bulkDownload(
|
|||||||
const isNotComplete = headers['x-immich-archive-complete'] === 'false';
|
const isNotComplete = headers['x-immich-archive-complete'] === 'false';
|
||||||
const fileCount = Number(headers['x-immich-archive-file-count']) || 0;
|
const fileCount = Number(headers['x-immich-archive-file-count']) || 0;
|
||||||
if (isNotComplete && fileCount > 0) {
|
if (isNotComplete && fileCount > 0) {
|
||||||
skip += fileCount;
|
// skip += fileCount;
|
||||||
} else {
|
} else {
|
||||||
onDone();
|
onDone();
|
||||||
done = true;
|
done = true;
|
||||||
|
@ -1,19 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
|
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
|
||||||
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
|
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
|
||||||
|
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
|
||||||
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
|
||||||
import StorageTemplateSettings from '$lib/components/admin-page/settings/storate-template/storage-template-settings.svelte';
|
import StorageTemplateSettings from '$lib/components/admin-page/settings/storate-template/storage-template-settings.svelte';
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { api, SystemConfigDto } from '@api';
|
import { api } from '@api';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
let systemConfig: SystemConfigDto;
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const getConfig = async () => {
|
const getConfig = async () => {
|
||||||
const { data } = await api.systemConfigApi.getConfig();
|
const { data } = await api.systemConfigApi.getConfig();
|
||||||
systemConfig = data;
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -33,7 +32,14 @@
|
|||||||
<FFmpegSettings ffmpegConfig={configs.ffmpeg} />
|
<FFmpegSettings ffmpegConfig={configs.ffmpeg} />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion title="OAuth Settings" subtitle="Manage the OAuth integration to Immich app">
|
<SettingAccordion
|
||||||
|
title="Password Authentication"
|
||||||
|
subtitle="Manage login with password settings"
|
||||||
|
>
|
||||||
|
<PasswordLoginSettings passwordLoginConfig={configs.passwordLogin} />
|
||||||
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion title="OAuth Authentication" subtitle="Manage the login with OAuth settings">
|
||||||
<OAuthSettings oauthConfig={configs.oauth} />
|
<OAuthSettings oauthConfig={configs.oauth} />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user