1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(server,web): migrate oauth settings from env to system config (#1061)

This commit is contained in:
Jason Rasmussen 2022-12-09 15:51:42 -05:00 committed by GitHub
parent cefdd86b7f
commit 5e680551b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1489 additions and 863 deletions

View File

@ -4,6 +4,9 @@ dev:
dev-new:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new-update:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-update:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans

View File

@ -28,10 +28,10 @@ Before enabling OAuth in Immich, a new client application needs to be configured
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
The **Sign-in redirect URIs** should include:
* All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
* Mobile app redirect URL `app.immich:/`
- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
- Mobile app redirect URL `app.immich:/`
:::caution
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
@ -42,17 +42,17 @@ You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobi
## Enable OAuth
Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
Once you have a new OAuth client application configured, Immich can be configured using the Administration Settings page, available on the web (Administration -> Settings).
| Key | Type | Default | Description |
| Setting | Type | Default | Description |
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) |
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step) |
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
| OAuth enabled | boolean | false | Enable/disable OAuth2 |
| OAuth issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| OAuth client ID | string | (required) | Required. Client ID (from previous step) |
| OAuth client secret | string | (required) | Required. Client Secret (previous step) |
| OAuth scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| OAuth button text | string | Login with OAuth | Text for the OAuth button on the web |
| OAuth auto register | boolean | true | When true, will automatically register a user the first time they sign in |
:::info
The Issuer URL should look something like the following, and return a valid json document.
@ -63,14 +63,4 @@ The Issuer URL should look something like the following, and return a valid json
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
:::
Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
```
OAUTH_ENABLED=true
OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
OAUTH_BUTTON_TEXT=Login with Authentik
```
[oidc]: https://openid.net/connect/

View File

@ -61,9 +61,9 @@ doc/ServerVersionReponseDto.md
doc/SignUpDto.md
doc/SmartInfoResponseDto.md
doc/SystemConfigApi.md
doc/SystemConfigKey.md
doc/SystemConfigResponseDto.md
doc/SystemConfigResponseItem.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigOAuthDto.md
doc/TagApi.md
doc/TagResponseDto.md
doc/TagTypeEnum.md
@ -149,9 +149,9 @@ lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart
lib/model/system_config_key.dart
lib/model/system_config_response_dto.dart
lib/model/system_config_response_item.dart
lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_o_auth_dto.dart
lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart
@ -224,9 +224,9 @@ test/server_version_reponse_dto_test.dart
test/sign_up_dto_test.dart
test/smart_info_response_dto_test.dart
test/system_config_api_test.dart
test/system_config_key_test.dart
test/system_config_response_dto_test.dart
test/system_config_response_item_test.dart
test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart
test/system_config_o_auth_dto_test.dart
test/tag_api_test.dart
test/tag_response_dto_test.dart
test/tag_type_enum_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,4 @@
import { ImmichConfigModule } from '@app/immich-config';
import { Module } from '@nestjs/common';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { UserModule } from '../user/user.module';
@ -5,7 +6,7 @@ import { OAuthController } from './oauth.controller';
import { OAuthService } from './oauth.service';
@Module({
imports: [UserModule, ImmichJwtModule],
imports: [UserModule, ImmichJwtModule, ImmichConfigModule],
controllers: [OAuthController],
providers: [OAuthService],
exports: [OAuthService],

View File

@ -1,24 +1,13 @@
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichConfigService } from '@app/immich-config';
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generators, Issuer } from 'openid-client';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { OAuthService } from '../oauth/oauth.service';
import { IUserRepository } from '../user/user-repository';
interface OAuthConfig {
OAUTH_ENABLED: boolean;
OAUTH_AUTO_REGISTER: boolean;
OAUTH_ISSUER_URL: string;
OAUTH_SCOPE: string;
OAUTH_BUTTON_TEXT: string;
}
const mockConfig = (config: Partial<OAuthConfig>) => {
return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null;
};
const email = 'user@immich.com';
const sub = 'my-auth-user-sub';
@ -39,7 +28,7 @@ const loginResponse = {
describe('OAuthService', () => {
let sut: OAuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let configServiceMock: jest.Mocked<ConfigService>;
let immichConfigServiceMock: jest.Mocked<ImmichConfigService>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
beforeEach(async () => {
@ -80,11 +69,11 @@ describe('OAuthService', () => {
extractJwtFromCookie: jest.fn(),
} as unknown as jest.Mocked<ImmichJwtService>;
configServiceMock = {
get: jest.fn(),
} as unknown as jest.Mocked<ConfigService>;
immichConfigServiceMock = {
getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }),
} as unknown as jest.Mocked<ImmichConfigService>;
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
});
it('should be defined', () => {
@ -94,17 +83,17 @@ describe('OAuthService', () => {
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
expect(configServiceMock.get).toHaveBeenCalled();
expect(immichConfigServiceMock.getConfig).toHaveBeenCalled();
});
it('should generate the config', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_BUTTON_TEXT: 'OAuth',
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
buttonText: 'OAuth',
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
@ -119,13 +108,13 @@ describe('OAuthService', () => {
});
it('should not allow auto registering', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_AUTO_REGISTER: false,
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: false,
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null);
@ -136,13 +125,13 @@ describe('OAuthService', () => {
});
it('should link an existing user', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_AUTO_REGISTER: false,
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: false,
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(user);
@ -156,8 +145,13 @@ describe('OAuthService', () => {
});
it('should allow auto registering by default', async () => {
configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: true,
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null);
@ -178,13 +172,13 @@ describe('OAuthService', () => {
});
it('should get the session endpoint from the discovery document', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_ISSUER_URL: 'http://issuer',
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
issuerUrl: 'http://issuer,',
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
});

View File

@ -1,5 +1,5 @@
import { ImmichConfigService } from '@app/immich-config';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
@ -16,43 +16,26 @@ type OAuthProfile = UserinfoResponse & {
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
private readonly enabled: boolean;
private readonly autoRegister: boolean;
private readonly buttonText: string;
private readonly issuerUrl: string;
private readonly clientMetadata: ClientMetadata;
private readonly scope: string;
constructor(
private immichJwtService: ImmichJwtService,
configService: ConfigService,
private immichConfigService: ImmichConfigService,
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
) {
this.enabled = configService.get('OAUTH_ENABLED', false);
this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true);
this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', '');
this.scope = configService.get<string>('OAUTH_SCOPE', '');
this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', '');
this.clientMetadata = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
client_id: configService.get('OAUTH_CLIENT_ID')!,
client_secret: configService.get('OAUTH_CLIENT_SECRET'),
response_types: ['code'],
};
}
) {}
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
if (!this.enabled) {
const config = await this.immichConfigService.getConfig();
const { enabled, scope, buttonText } = config.oauth;
if (!enabled) {
return { enabled: false };
}
const url = (await this.getClient()).authorizationUrl({
redirect_uri: dto.redirectUri,
scope: this.scope,
scope,
state: generators.state(),
});
return { enabled: true, buttonText: this.buttonText, url };
return { enabled: true, buttonText, url };
}
public async callback(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
@ -75,9 +58,11 @@ export class OAuthService {
// register new user
if (!user) {
if (!this.autoRegister) {
const config = await this.immichConfigService.getConfig();
const { autoRegister } = config.oauth;
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`,
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
@ -95,20 +80,31 @@ export class OAuthService {
}
public async getLogoutEndpoint(): Promise<string | null> {
if (!this.enabled) {
const config = await this.immichConfigService.getConfig();
const { enabled } = config.oauth;
if (!enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async getClient() {
if (!this.enabled) {
const config = await this.immichConfigService.getConfig();
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const issuer = await Issuer.discover(this.issuerUrl);
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
const metadata = { ...this.clientMetadata };
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}

View File

@ -0,0 +1,18 @@
import { IsString } from 'class-validator';
export class SystemConfigFFmpegDto {
@IsString()
crf!: string;
@IsString()
preset!: string;
@IsString()
targetVideoCodec!: string;
@IsString()
targetAudioCodec!: string;
@IsString()
targetScaling!: string;
}

View File

@ -0,0 +1,32 @@
import { IsBoolean, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
export class SystemConfigOAuthDto {
@IsBoolean()
enabled!: boolean;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString()
issuerUrl!: string;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString()
clientId!: string;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString()
clientSecret!: string;
@IsString()
scope!: string;
@IsString()
buttonText!: string;
@IsBoolean()
autoRegister!: boolean;
}

View File

@ -0,0 +1,16 @@
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
export class SystemConfigDto {
@ValidateNested()
ffmpeg!: SystemConfigFFmpegDto;
@ValidateNested()
oauth!: SystemConfigOAuthDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {
return config;
}

View File

@ -1,20 +0,0 @@
import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, ValidateNested } from 'class-validator';
export class UpdateSystemConfigDto {
@IsNotEmpty()
@ValidateNested({ each: true })
config!: SystemConfigItem[];
}
export class SystemConfigItem {
@IsNotEmpty()
@IsEnum(SystemConfigKey)
@ApiProperty({
enum: SystemConfigKey,
enumName: 'SystemConfigKey',
})
key!: SystemConfigKey;
value!: SystemConfigValue;
}

View File

@ -1,20 +0,0 @@
import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
import { ApiProperty } from '@nestjs/swagger';
export class SystemConfigResponseDto {
config!: SystemConfigResponseItem[];
}
export class SystemConfigResponseItem {
@ApiProperty({ type: 'string' })
name!: string;
@ApiProperty({ enumName: 'SystemConfigKey', enum: SystemConfigKey })
key!: SystemConfigKey;
@ApiProperty({ type: 'string' })
value!: SystemConfigValue;
@ApiProperty({ type: 'string' })
defaultValue!: SystemConfigValue;
}

View File

@ -1,8 +1,7 @@
import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { UpdateSystemConfigDto } from './dto/update-system-config';
import { SystemConfigResponseDto } from './response-dto/system-config-response.dto';
import { SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigService } from './system-config.service';
@ApiTags('System Config')
@ -13,12 +12,17 @@ export class SystemConfigController {
constructor(private readonly systemConfigService: SystemConfigService) {}
@Get()
getConfig(): Promise<SystemConfigResponseDto> {
public getConfig(): Promise<SystemConfigDto> {
return this.systemConfigService.getConfig();
}
@Get('defaults')
public getDefaults(): SystemConfigDto {
return this.systemConfigService.getDefaults();
}
@Put()
async updateConfig(@Body(ValidationPipe) dto: UpdateSystemConfigDto): Promise<SystemConfigResponseDto> {
public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.systemConfigService.updateConfig(dto);
}
}

View File

@ -1,20 +1,23 @@
import { Injectable } from '@nestjs/common';
import { ImmichConfigService } from 'libs/immich-config/src';
import { UpdateSystemConfigDto } from './dto/update-system-config';
import { SystemConfigResponseDto } from './response-dto/system-config-response.dto';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
@Injectable()
export class SystemConfigService {
constructor(private immichConfigService: ImmichConfigService) {}
async getConfig(): Promise<SystemConfigResponseDto> {
const config = await this.immichConfigService.getSystemConfig();
return { config };
public async getConfig(): Promise<SystemConfigDto> {
const config = await this.immichConfigService.getConfig();
return mapConfig(config);
}
async updateConfig(dto: UpdateSystemConfigDto): Promise<SystemConfigResponseDto> {
await this.immichConfigService.updateSystemConfig(dto.config);
const config = await this.immichConfigService.getSystemConfig();
return { config };
public getDefaults(): SystemConfigDto {
const config = this.immichConfigService.getDefaults();
return mapConfig(config);
}
public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
await this.immichConfigService.updateConfig(dto);
return this.getConfig();
}
}

View File

@ -42,16 +42,16 @@ export class VideoTranscodeProcessor {
}
async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
const config = await this.immichConfigService.getSystemConfigMap();
const config = await this.immichConfigService.getConfig();
return new Promise((resolve, reject) => {
ffmpeg(asset.originalPath)
.outputOptions([
`-crf ${config.ffmpeg_crf}`,
`-preset ${config.ffmpeg_preset}`,
`-vcodec ${config.ffmpeg_target_video_codec}`,
`-acodec ${config.ffmpeg_target_audio_codec}`,
`-vf scale=${config.ffmpeg_target_scaling}`,
`-crf ${config.ffmpeg.crf}`,
`-preset ${config.ffmpeg.preset}`,
`-vcodec ${config.ffmpeg.targetVideoCodec}`,
`-acodec ${config.ffmpeg.targetAudioCodec}`,
`-vf scale=${config.ffmpeg.targetScaling}`,
])
.output(savedEncodedPath)
.on('start', () => {

View File

@ -2086,7 +2086,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigResponseDto"
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
@ -2109,7 +2109,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSystemConfigDto"
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
@ -2120,7 +2120,33 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigResponseDto"
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
}
},
"tags": [
"System Config"
],
"security": [
{
"bearer": []
}
]
}
},
"/system-config/defaults": {
"get": {
"operationId": "getDefaults",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
@ -3568,56 +3594,82 @@
"command"
]
},
"SystemConfigKey": {
"type": "string",
"enum": [
"ffmpeg_crf",
"ffmpeg_preset",
"ffmpeg_target_video_codec",
"ffmpeg_target_audio_codec",
"ffmpeg_target_scaling"
]
},
"SystemConfigResponseItem": {
"SystemConfigFFmpegDto": {
"type": "object",
"properties": {
"name": {
"crf": {
"type": "string"
},
"key": {
"$ref": "#/components/schemas/SystemConfigKey"
},
"value": {
"preset": {
"type": "string"
},
"defaultValue": {
"targetVideoCodec": {
"type": "string"
},
"targetAudioCodec": {
"type": "string"
},
"targetScaling": {
"type": "string"
}
},
"required": [
"name",
"key",
"value",
"defaultValue"
"crf",
"preset",
"targetVideoCodec",
"targetAudioCodec",
"targetScaling"
]
},
"SystemConfigResponseDto": {
"SystemConfigOAuthDto": {
"type": "object",
"properties": {
"config": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigResponseItem"
}
"enabled": {
"type": "boolean"
},
"issuerUrl": {
"type": "string"
},
"clientId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"scope": {
"type": "string"
},
"buttonText": {
"type": "string"
},
"autoRegister": {
"type": "boolean"
}
},
"required": [
"config"
"enabled",
"issuerUrl",
"clientId",
"clientSecret",
"scope",
"buttonText",
"autoRegister"
]
},
"UpdateSystemConfigDto": {
"SystemConfigDto": {
"type": "object",
"properties": {}
"properties": {
"ffmpeg": {
"$ref": "#/components/schemas/SystemConfigFFmpegDto"
},
"oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto"
}
},
"required": [
"ffmpeg",
"oauth"
]
}
}
}

View File

@ -16,12 +16,6 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
return value;
};
const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', {
is: true,
then: Joi.string().required(),
otherwise: Joi.string().optional(),
});
export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
isGlobal: true,
@ -34,12 +28,5 @@ export const immichAppConfig: ConfigModuleOptions = {
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
OAUTH_ENABLED: Joi.bool().valid(true, false).default(false),
OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'),
OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true),
OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED,
OAUTH_SCOPE: Joi.string().optional().default('openid email profile'),
OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED,
OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED,
}),
};

View File

@ -1,27 +1,47 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_config')
export class SystemConfigEntity {
export class SystemConfigEntity<T = string> {
@PrimaryColumn()
key!: SystemConfigKey;
@Column({ type: 'varchar', nullable: true })
value!: SystemConfigValue;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
value!: T;
}
export type SystemConfig = SystemConfigEntity[];
export type SystemConfigValue = any;
// dot notation matches path in `SystemConfig`
export enum SystemConfigKey {
FFMPEG_CRF = 'ffmpeg_crf',
FFMPEG_PRESET = 'ffmpeg_preset',
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg_target_video_codec',
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg_target_audio_codec',
FFMPEG_TARGET_SCALING = 'ffmpeg_target_scaling',
FFMPEG_CRF = 'ffmpeg.crf',
FFMPEG_PRESET = 'ffmpeg.preset',
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
FFMPEG_TARGET_SCALING = 'ffmpeg.targetScaling',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId',
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
OAUTH_SCOPE = 'oauth.scope',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
}
export type SystemConfigValue = string | null;
export interface SystemConfigItem {
key: SystemConfigKey;
value: SystemConfigValue;
export interface SystemConfig {
ffmpeg: {
crf: string;
preset: string;
targetVideoCodec: string;
targetAudioCodec: string;
targetScaling: string;
};
oauth: {
enabled: boolean;
issuerUrl: string;
clientId: string;
clientSecret: string;
scope: string;
buttonText: string;
autoRegister: boolean;
};
}

View File

@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TruncateOldConfigItems1670607437008 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`TRUNCATE TABLE "system_config"`);
}
public async down(): Promise<void> {
// noop
}
}

View File

@ -1,32 +1,27 @@
import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import * as _ from 'lodash';
import { DeepPartial, In, Repository } from 'typeorm';
type SystemConfigMap = Record<SystemConfigKey, SystemConfigValue>;
const configDefaults: Record<SystemConfigKey, { name: string; value: SystemConfigValue }> = {
[SystemConfigKey.FFMPEG_CRF]: {
name: 'FFmpeg Constant Rate Factor (-crf)',
value: '23',
const defaults: SystemConfig = Object.freeze({
ffmpeg: {
crf: '23',
preset: 'ultrafast',
targetVideoCodec: 'libx264',
targetAudioCodec: 'mp3',
targetScaling: '1280:-2',
},
[SystemConfigKey.FFMPEG_PRESET]: {
name: 'FFmpeg preset (-preset)',
value: 'ultrafast',
oauth: {
enabled: false,
issuerUrl: '',
clientId: '',
clientSecret: '',
scope: 'openid email profile',
buttonText: 'Login with OAuth',
autoRegister: true,
},
[SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC]: {
name: 'FFmpeg target video codec (-vcodec)',
value: 'libx264',
},
[SystemConfigKey.FFMPEG_TARGET_AUDIO_CODEC]: {
name: 'FFmpeg target audio codec (-acodec)',
value: 'mp3',
},
[SystemConfigKey.FFMPEG_TARGET_SCALING]: {
name: 'FFmpeg target scaling (-vf scale=)',
value: '1280:-2',
},
};
});
@Injectable()
export class ImmichConfigService {
@ -35,38 +30,32 @@ export class ImmichConfigService {
private systemConfigRepository: Repository<SystemConfigEntity>,
) {}
public async getSystemConfig() {
const items = this._getDefaults();
public getDefaults(): SystemConfig {
return defaults;
}
// override default values
public async getConfig() {
const overrides = await this.systemConfigRepository.find();
for (const override of overrides) {
const item = items.find((_item) => _item.key === override.key);
if (item) {
item.value = override.value;
}
const config: DeepPartial<SystemConfig> = {};
for (const { key, value } of overrides) {
// set via dot notation
_.set(config, key, value);
}
return items;
return _.defaultsDeep(config, defaults) as SystemConfig;
}
public async getSystemConfigMap(): Promise<SystemConfigMap> {
const items = await this.getSystemConfig();
const map: Partial<SystemConfigMap> = {};
for (const { key, value } of items) {
map[key] = value;
}
return map as SystemConfigMap;
}
public async updateSystemConfig(items: SystemConfigEntity[]): Promise<void> {
const deletes: SystemConfigEntity[] = [];
public async updateConfig(config: DeepPartial<SystemConfig> | null): Promise<void> {
const updates: SystemConfigEntity[] = [];
const deletes: SystemConfigEntity[] = [];
for (const item of items) {
if (item.value === null || item.value === this._getDefaultValue(item.key)) {
for (const key of Object.values(SystemConfigKey)) {
// get via dot notation
const item = { key, value: _.get(config, key) };
const defaultValue = _.get(defaults, key);
const isMissing = !_.has(config, key);
if (isMissing || item.value === null || item.value === '' || item.value === defaultValue) {
deletes.push(item);
continue;
}
@ -82,16 +71,4 @@ export class ImmichConfigService {
await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
}
}
private _getDefaults() {
return Object.values(SystemConfigKey).map((key) => ({
key,
defaultValue: configDefaults[key].value,
...configDefaults[key],
}));
}
private _getDefaultValue(key: SystemConfigKey) {
return this._getDefaults().find((item) => item.key === key)?.value || null;
}
}

View File

@ -71,13 +71,13 @@
"tsConfigPath": "libs/job/tsconfig.lib.json"
}
},
"system-config": {
"immich-config": {
"type": "library",
"root": "libs/system-config",
"root": "libs/immich-config",
"entryFile": "index",
"sourceRoot": "libs/system-config/src",
"sourceRoot": "libs/immich-config/src",
"compilerOptions": {
"tsConfigPath": "libs/system-config/tsconfig.lib.json"
"tsConfigPath": "libs/immich-config/tsconfig.lib.json"
}
}
}

View File

@ -142,7 +142,7 @@
"@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1",
"^@app/system-config(|/.*)$": "<rootDir>/libs/system-config/src/$1"
"^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1"
}
}
}

View File

@ -22,8 +22,8 @@
"@app/database/*": ["libs/database/src/*"],
"@app/job": ["libs/job/src"],
"@app/job/*": ["libs/job/src/*"],
"@app/system-config": ["libs/immich-config/src"],
"@app/system-config/*": ["libs/immich-config/src/*"]
"@app/immich-config": ["libs/immich-config/src"],
"@app/immich-config/*": ["libs/immich-config/src/*"]
}
},
"exclude": ["dist", "node_modules", "upload"]

View File

@ -1428,63 +1428,107 @@ export interface SmartInfoResponseDto {
/**
*
* @export
* @enum {string}
* @interface SystemConfigDto
*/
export const SystemConfigKey = {
Crf: 'ffmpeg_crf',
Preset: 'ffmpeg_preset',
TargetVideoCodec: 'ffmpeg_target_video_codec',
TargetAudioCodec: 'ffmpeg_target_audio_codec',
TargetScaling: 'ffmpeg_target_scaling'
} as const;
export type SystemConfigKey = typeof SystemConfigKey[keyof typeof SystemConfigKey];
/**
*
* @export
* @interface SystemConfigResponseDto
*/
export interface SystemConfigResponseDto {
export interface SystemConfigDto {
/**
*
* @type {Array<SystemConfigResponseItem>}
* @memberof SystemConfigResponseDto
* @type {SystemConfigFFmpegDto}
* @memberof SystemConfigDto
*/
'config': Array<SystemConfigResponseItem>;
'ffmpeg': SystemConfigFFmpegDto;
/**
*
* @type {SystemConfigOAuthDto}
* @memberof SystemConfigDto
*/
'oauth': SystemConfigOAuthDto;
}
/**
*
* @export
* @interface SystemConfigResponseItem
* @interface SystemConfigFFmpegDto
*/
export interface SystemConfigResponseItem {
export interface SystemConfigFFmpegDto {
/**
*
* @type {string}
* @memberof SystemConfigResponseItem
* @memberof SystemConfigFFmpegDto
*/
'name': string;
/**
*
* @type {SystemConfigKey}
* @memberof SystemConfigResponseItem
*/
'key': SystemConfigKey;
'crf': string;
/**
*
* @type {string}
* @memberof SystemConfigResponseItem
* @memberof SystemConfigFFmpegDto
*/
'value': string;
'preset': string;
/**
*
* @type {string}
* @memberof SystemConfigResponseItem
* @memberof SystemConfigFFmpegDto
*/
'defaultValue': string;
'targetVideoCodec': string;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'targetAudioCodec': string;
/**
*
* @type {string}
* @memberof SystemConfigFFmpegDto
*/
'targetScaling': string;
}
/**
*
* @export
* @interface SystemConfigOAuthDto
*/
export interface SystemConfigOAuthDto {
/**
*
* @type {boolean}
* @memberof SystemConfigOAuthDto
*/
'enabled': boolean;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'issuerUrl': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'clientId': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'clientSecret': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'scope': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'buttonText': string;
/**
*
* @type {boolean}
* @memberof SystemConfigOAuthDto
*/
'autoRegister': boolean;
}
/**
*
@ -5254,13 +5298,46 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
},
/**
*
* @param {object} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateConfig: async (body: object, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'body' is not null or undefined
assertParamExists('updateConfig', 'body', body)
getDefaults: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/system-config/defaults`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {SystemConfigDto} systemConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateConfig: async (systemConfigDto: SystemConfigDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'systemConfigDto' is not null or undefined
assertParamExists('updateConfig', 'systemConfigDto', systemConfigDto)
const localVarPath = `/system-config`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -5284,7 +5361,7 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(systemConfigDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -5306,18 +5383,27 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigResponseDto>> {
async getConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getConfig(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {object} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateConfig(body: object, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateConfig(body, options);
async getDefaults(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {SystemConfigDto} systemConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateConfig(systemConfigDto: SystemConfigDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateConfig(systemConfigDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@ -5335,17 +5421,25 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getConfig(options?: any): AxiosPromise<SystemConfigResponseDto> {
getConfig(options?: any): AxiosPromise<SystemConfigDto> {
return localVarFp.getConfig(options).then((request) => request(axios, basePath));
},
/**
*
* @param {object} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateConfig(body: object, options?: any): AxiosPromise<SystemConfigResponseDto> {
return localVarFp.updateConfig(body, options).then((request) => request(axios, basePath));
getDefaults(options?: any): AxiosPromise<SystemConfigDto> {
return localVarFp.getDefaults(options).then((request) => request(axios, basePath));
},
/**
*
* @param {SystemConfigDto} systemConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateConfig(systemConfigDto: SystemConfigDto, options?: any): AxiosPromise<SystemConfigDto> {
return localVarFp.updateConfig(systemConfigDto, options).then((request) => request(axios, basePath));
},
};
};
@ -5369,13 +5463,23 @@ export class SystemConfigApi extends BaseAPI {
/**
*
* @param {object} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SystemConfigApi
*/
public updateConfig(body: object, options?: AxiosRequestConfig) {
return SystemConfigApiFp(this.configuration).updateConfig(body, options).then((request) => request(this.axios, this.basePath));
public getDefaults(options?: AxiosRequestConfig) {
return SystemConfigApiFp(this.configuration).getDefaults(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {SystemConfigDto} systemConfigDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SystemConfigApi
*/
public updateConfig(systemConfigDto: SystemConfigDto, options?: AxiosRequestConfig) {
return SystemConfigApiFp(this.configuration).updateConfig(systemConfigDto, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@ -59,7 +59,7 @@ input:focus-visible {
@layer utilities {
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md dark:text-immich-dark-bg focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg;
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-500 dark:disabled:bg-gray-900 disabled:cursor-not-allowed;
}
.immich-form-label {

View File

@ -0,0 +1,126 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigFFmpegDto } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import _ from 'lodash';
export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited
import { fade } from 'svelte/transition';
let savedConfig: SystemConfigFFmpegDto;
let defaultConfig: SystemConfigFFmpegDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.ffmpeg),
api.systemConfigApi.getDefaults().then((res) => res.data.ffmpeg)
]);
}
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
ffmpeg: ffmpegConfig,
oauth: configs.oauth
});
ffmpegConfig = result.data.ffmpeg;
savedConfig = result.data.ffmpeg;
notificationController.show({
message: 'FFmpeg settings saved',
type: NotificationType.Info
});
} catch (e) {
console.error('Error [ffmpeg-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error
});
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
ffmpegConfig = resetConfig.ffmpeg;
savedConfig = resetConfig.ffmpeg;
notificationController.show({
message: 'Reset FFmpeg settings to the recent saved settings',
type: NotificationType.Info
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
ffmpegConfig = configs.ffmpeg;
defaultConfig = configs.ffmpeg;
notificationController.show({
message: 'Reset FFmpeg settings to default',
type: NotificationType.Info
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="CRF"
bind:value={ffmpegConfig.crf}
required={true}
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PRESET"
bind:value={ffmpegConfig.preset}
required={true}
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="AUDIO CODEC"
bind:value={ffmpegConfig.targetAudioCodec}
required={true}
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="VIDEO CODEC"
bind:value={ffmpegConfig.targetVideoCodec}
required={true}
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCALING"
bind:value={ffmpegConfig.targetScaling}
required={true}
isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
/>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
{/await}
</div>

View File

@ -0,0 +1,147 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigOAuthDto } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import _ from 'lodash';
import { fade } from 'svelte/transition';
export let oauthConfig: SystemConfigOAuthDto;
let savedConfig: SystemConfigOAuthDto;
let defaultConfig: SystemConfigOAuthDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.oauth),
api.systemConfigApi.getDefaults().then((res) => res.data.oauth)
]);
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
oauthConfig = resetConfig.oauth;
savedConfig = resetConfig.oauth;
notificationController.show({
message: 'Reset OAuth settings to the recent saved settings',
type: NotificationType.Info
});
}
async function saveSetting() {
try {
const { data: currentConfig } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
ffmpeg: currentConfig.ffmpeg,
oauth: oauthConfig
});
oauthConfig = result.data.oauth;
savedConfig = result.data.oauth;
notificationController.show({
message: 'OAuth settings saved',
type: NotificationType.Info
});
} catch (e) {
console.error('Error [oauth-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error
});
}
}
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
oauthConfig = defaultConfig.oauth;
notificationController.show({
message: 'Reset OAuth settings to default',
type: NotificationType.Info
});
}
</script>
<div class="mt-2">
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="mt-4">
<SettingSwitch title="Enable" bind:checked={oauthConfig.enabled} />
</div>
<hr class="m-4" />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={oauthConfig.issuerUrl}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={oauthConfig.clientId}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={oauthConfig.clientSecret}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={oauthConfig.scope}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.scope == savedConfig.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={oauthConfig.buttonText}
required={false}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
/>
<div class="mt-4">
<SettingSwitch
title="AUTO REGISTER"
subtitle="Automatically register new users after singning in with OAuth"
bind:checked={oauthConfig.autoRegister}
disabled={!oauthConfig.enabled}
/>
</div>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
{/await}
</div>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { slide } from 'svelte/transition';
export let title: string;
export let subtitle = '';
let isOpen = false;
const toggle = () => (isOpen = !isOpen);
</script>
<div class="border-b-[1px] border-gray-200 dark:border-gray-700 py-4">
<div class="flex justify-between place-items-center">
<div>
<h2 class="font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h2>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<button
on:click={toggle}
aria-expanded={isOpen}
class="immich-circle-icon-button hover:bg-immich-primary/10 dark:text-immich-dark-fg hover:dark:bg-immich-dark-primary/20 rounded-full p-3 flex place-items-center place-content-center transition-all"
>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{#if isOpen}
<ul transition:slide={{ duration: 250 }} class="mb-2 ml-4">
<slot />
</ul>
{/if}
</div>
<style>
svg {
transition: transform 0.2s ease-in;
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
</style>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let showResetToDefault = true;
</script>
<div class="flex justify-between gap-2 mx-4 mt-8">
<div class="left">
{#if showResetToDefault}
<button
on:click|preventDefault={() => dispatch('reset-to-default')}
class="text-sm dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75 text-immich-primary hover:text-immich-primary/75 font-medium bg-none"
>
Reset to default
</button>
{/if}
</div>
<div class="right">
<button
on:click|preventDefault={() => dispatch('reset')}
class="text-sm bg-gray-500 dark:bg-gray-200 hover:bg-gray-500/75 dark:hover:bg-gray-200/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"
>Reset
</button>
<button
type="submit"
on:click={() => dispatch('save')}
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>

View File

@ -0,0 +1,51 @@
<script lang="ts" context="module">
export enum SettingInputFieldType {
TEXT = 'text',
NUMBER = 'number',
PASSWORD = 'password'
}
</script>
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
export let inputType: SettingInputFieldType;
export let value: string;
export let label: string;
export let required = false;
export let disabled = false;
export let isEdited: boolean;
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
};
</script>
<div class="m-4 flex flex-col gap-2">
<div class="flex place-items-center gap-1">
<label class="immich-form-label" for={label}>{label.toUpperCase()} </label>
{#if required}
<div class="text-red-400">*</div>
{/if}
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="text-gray-500 text-xs italic"
>
Unsaved change
</div>
{/if}
</div>
<input
class="immich-form-input"
id={label}
name={label}
type={inputType}
{required}
{value}
on:input={handleInput}
{disabled}
/>
</div>

View File

@ -0,0 +1,81 @@
<script lang="ts">
export let title: string;
export let subtitle = '';
export let checked = false;
export let disabled = false;
</script>
<div class="flex justify-between mx-4 place-items-center">
<div>
<h2 class="immich-form-label">
{title.toUpperCase()}
</h2>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<label class="relative inline-block w-[36px] h-[10px]" {disabled}>
<input
class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
type="checkbox"
bind:checked
{disabled}
/>
{#if disabled}
<span class="slider-disable" />
{:else}
<span class="slider" />
{/if}
</label>
</div>
<style>
.slider,
.slider-disable {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 34px;
}
input:disabled {
cursor: not-allowed;
}
.slider:before,
.slider-disable:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 0px;
right: 0px;
bottom: -4px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider-disable {
background-color: gray;
}
input:checked + .slider {
background-color: #adcbfa;
}
input:checked + .slider:before {
-webkit-transform: translateX(18px);
-ms-transform: translateX(18px);
transform: translateX(18px);
background-color: #4250af;
}
</style>

View File

@ -1,97 +0,0 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigResponseItem } from '@api';
import { onMount } from 'svelte';
let isSaving = false;
let items: Array<SystemConfigResponseItem & { originalValue: string }> = [];
const refreshConfig = async () => {
const { data: systemConfig } = await api.systemConfigApi.getConfig();
items = systemConfig.config.map((item) => ({ ...item, originalValue: item.value }));
};
onMount(() => refreshConfig());
const handleSave = async () => {
try {
isSaving = true;
const updates = items
.filter((item) => item.value !== item.originalValue)
.map(({ key, value }) => ({ key, value: value || null }));
if (updates.length > 0) {
await api.systemConfigApi.updateConfig({ config: updates });
refreshConfig();
}
notificationController.show({
message: `Saved settings`,
type: NotificationType.Info
});
} catch (e) {
console.error('Error [updateSystemConfig]', e);
notificationController.show({
message: `Unable to save changes.`,
type: NotificationType.Error
});
} finally {
isSaving = false;
}
};
</script>
<section>
<table class="text-left my-4 w-full">
<thead
class="border rounded-md mb-4 bg-gray-50 flex text-immich-primary h-12 dark:bg-immich-dark-gray dark:text-immich-dark-primary dark:border-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/2 font-medium text-sm">Setting</th>
<th class="text-center w-1/2 font-medium text-sm">Value</th>
</tr>
</thead>
<tbody class="rounded-md block border dark:border-immich-dark-gray">
{#each items as item, i}
<tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-fg ${
i % 2 == 0 ? 'bg-slate-50 dark:bg-[#181818]' : 'bg-immich-bg dark:bg-immich-dark-bg'
}`}
>
<td class="text-sm px-4 w-1/2 text-ellipsis">
{item.name}
</td>
<td class="text-sm px-4 w-1/2 text-ellipsis">
<input
style="text-align: center"
class="immich-form-input"
id={item.key}
disabled={isSaving}
name={item.key}
type="text"
bind:value={item.value}
placeholder={item.defaultValue + ''}
/>
</td>
</tr>
{/each}
</tbody>
</table>
<div class="flex justify-end">
<button
on:click={handleSave}
class="px-6 py-3 text-sm bg-immich-primary dark:bg-immich-dark-primary font-medium rounded-2xl hover:bg-immich-primary/50 transition-all hover:cursor-pointer disabled:cursor-not-allowed shadow-sm text-immich-bg dark:text-immich-dark-gray"
disabled={isSaving}
>
{#if isSaving}
<LoadingSpinner />
{:else}
Save
{/if}
</button>
</div>
</section>

View File

@ -1,91 +0,0 @@
<script lang="ts">
import { UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
export let allUsers: Array<UserResponseDto>;
const dispatch = createEventDispatcher();
const isDeleted = (user: UserResponseDto): boolean => {
return user.deletedAt != null;
};
const locale = navigator.language;
const deleteDateFormat: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric'
};
const getDeleteDate = (user: UserResponseDto): string => {
let deletedAt = new Date(user.deletedAt ? user.deletedAt : Date.now());
deletedAt.setDate(deletedAt.getDate() + 7);
return deletedAt.toLocaleString(locale, deleteDateFormat);
};
</script>
<table class="text-left w-full my-5">
<thead
class="border rounded-md mb-4 bg-gray-50 flex text-immich-primary w-full h-12 dark:bg-immich-dark-gray dark:text-immich-dark-primary dark:border-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th>
<th class="text-center w-1/4 font-medium text-sm">Last name</th>
<th class="text-center w-1/4 font-medium text-sm">Action</th>
</tr>
</thead>
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray"
>
{#each allUsers as user, i}
<tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-fg ${
isDeleted(user)
? 'bg-red-300 dark:bg-red-900'
: i % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">
{#if !isDeleted(user)}
<button
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="16" /></button
>
<button
on:click={() => {
dispatch('delete-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><TrashCanOutline size="16" /></button
>
{/if}
{#if isDeleted(user)}
<button
on:click={() => {
dispatch('restore-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
title={`scheduled removal on ${getDeleteDate(user)}`}
><DeleteRestore size="16" /></button
>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<button on:click={() => dispatch('create-user')} class="immich-btn-primary">Create user</button>

View File

@ -9,7 +9,7 @@
<section
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
class="absolute w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center "
class="absolute left-0 top-0 w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center"
>
<div class="z-[9999]" use:clickOutside on:out-click={() => dispatch('clickOutside')}>
<slot />

View File

@ -7,6 +7,7 @@
import { clickOutside } from '../../utils/click-outside';
import { api, UserResponseDto } from '@api';
import ThemeButton from './theme-button.svelte';
import { AppRoute } from '../../constants';
export let user: UserResponseDto;
export let shouldShowUploadButton = true;
@ -70,7 +71,7 @@
<section class="flex gap-4 place-items-center">
<ThemeButton />
{#if $page.url.pathname !== '/admin' && shouldShowUploadButton}
{#if !$page.url.pathname.includes('/admin') && shouldShowUploadButton}
<button
in:fly={{ x: 50, duration: 250 }}
on:click={() => dispatch('uploadClicked')}
@ -82,10 +83,10 @@
{/if}
{#if user.isAdmin}
<a data-sveltekit-preload-data="hover" href={`admin`}>
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_USER_MANAGEMENT}>
<button
class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 dark:text-immich-dark-fg p-2 rounded-lg font-medium ${
$page.url.pathname == '/admin' &&
$page.url.pathname.includes('/admin') &&
'text-immich-primary dark:immich-dark-primary underline'
}`}>Administration</button
>

View File

@ -3,22 +3,12 @@
// TODO: why `any` here? There should be a expected type for this
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let logo: any;
export let actionType: AdminSideBarSelection | AppSideBarSelection;
export let isSelected: boolean;
import { createEventDispatcher } from 'svelte';
import type {
AdminSideBarSelection,
AppSideBarSelection
} from '../../../models/admin-sidebar-selection';
const dispatch = createEventDispatcher();
const onButtonClicked = () => {
dispatch('selected', {
actionType
});
};
const onButtonClicked = () => dispatch('selected');
</script>
<div

View File

@ -1,6 +1,4 @@
<script lang="ts">
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
@ -11,28 +9,12 @@
import { api } from '@api';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../loading-spinner.svelte';
let selectedAction: AppSideBarSelection;
import { AppRoute } from '../../../constants';
let showAssetCount = false;
let showSharingCount = false;
let showAlbumsCount = false;
// let domCount = 0;
onMount(async () => {
if ($page.route.id == 'albums') {
selectedAction = AppSideBarSelection.ALBUMS;
} else if ($page.route.id == 'photos') {
selectedAction = AppSideBarSelection.PHOTOS;
} else if ($page.route.id == 'sharing') {
selectedAction = AppSideBarSelection.SHARING;
}
// setInterval(() => {
// domCount = document.getElementsByTagName('*').length;
// }, 500);
});
const getAssetCount = async () => {
const { data: assetCount } = await api.assetApi.getAssetCountByUserId();
@ -56,14 +38,13 @@
<a
data-sveltekit-preload-data="hover"
data-sveltekit-noscroll
href={$page.route.id !== 'photos' ? `/photos` : null}
href={AppRoute.PHOTOS}
class="relative"
>
<SideBarButton
title={`Photos`}
logo={ImageOutline}
actionType={AppSideBarSelection.PHOTOS}
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
isSelected={$page.route.id === AppRoute.PHOTOS}
/>
<div
id="asset-count-info"
@ -75,7 +56,6 @@
{#if showAssetCount}
<div
transition:fade={{ duration: 200 }}
id="asset-count-info-detail"
class="w-32 rounded-lg p-4 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center"
>
{#await getAssetCount()}
@ -91,16 +71,11 @@
</div>
</a>
<a
data-sveltekit-preload-data="hover"
href={$page.route.id !== 'sharing' ? `/sharing` : null}
class="relative"
>
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} class="relative">
<SideBarButton
title="Sharing"
logo={AccountMultipleOutline}
actionType={AppSideBarSelection.SHARING}
isSelected={selectedAction === AppSideBarSelection.SHARING}
isSelected={$page.route.id === AppRoute.SHARING}
/>
<div
id="sharing-count-info"
@ -112,7 +87,6 @@
{#if showSharingCount}
<div
transition:fade={{ duration: 200 }}
id="asset-count-info-detail"
class="w-24 rounded-lg p-4 shadow-lg bg-white absolute -right-[105px] top-0 z-[9999] flex place-items-center place-content-center"
>
{#await getAlbumCount()}
@ -129,16 +103,11 @@
<div class="text-xs ml-5 my-4 dark:text-immich-dark-fg">
<p>LIBRARY</p>
</div>
<a
data-sveltekit-preload-data="hover"
href={$page.route.id !== 'albums' ? `/albums` : null}
class="relative"
>
<a data-sveltekit-preload-data="hover" href={AppRoute.ALBUMS} class="relative">
<SideBarButton
title="Albums"
logo={ImageAlbum}
actionType={AppSideBarSelection.ALBUMS}
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
isSelected={$page.route.id === AppRoute.ALBUMS}
/>
<div

View File

@ -1,2 +1,13 @@
import { env } from '$env/dynamic/public';
export const loginPageMessage: string | undefined = env.PUBLIC_LOGIN_PAGE_MESSAGE;
export enum AppRoute {
ADMIN_USER_MANAGEMENT = '/admin/user-management',
ADMIN_SETTINGS = '/admin/settings',
ADMIN_STATS = '/admin/server-status',
ADMIN_JOBS = '/admin/jobs-status',
ALBUMS = '/albums',
PHOTOS = '/photos',
SHARING = '/sharing'
}

View File

@ -1,13 +0,0 @@
export enum AdminSideBarSelection {
USER_MANAGEMENT = 'User management',
JOBS = 'Jobs',
SETTINGS = 'Settings',
STATS = 'Server Stats'
}
export enum AppSideBarSelection {
PHOTOS = 'Photos',
EXPLORE = 'Explore',
ALBUMS = 'Albums',
SHARING = 'Sharing'
}

View File

@ -1,3 +1,82 @@
<script lang="ts">
import { page } from '$app/stores';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import Sync from 'svelte-material-icons/Sync.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import Server from 'svelte-material-icons/Server.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import { goto } from '$app/navigation';
import { AppRoute } from '../../lib/constants';
const getPageTitle = (routeId: string | null) => {
switch (routeId) {
case AppRoute.ADMIN_USER_MANAGEMENT:
return 'User Management';
case AppRoute.ADMIN_SETTINGS:
return 'Settings';
case AppRoute.ADMIN_JOBS:
return 'Jobs';
case AppRoute.ADMIN_STATS:
return 'Server Stats';
default:
return '';
}
};
</script>
<svelte:head>
<title>Administration - Immich</title>
</svelte:head>
<NavigationBar user={$page.data.user} />
<main>
<slot />
<section class="grid grid-cols-[250px_auto] pt-[72px] h-screen">
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col gap-1">
<SideBarButton
title="Users"
logo={AccountMultipleOutline}
isSelected={$page.route.id === AppRoute.ADMIN_USER_MANAGEMENT}
on:selected={() => goto(AppRoute.ADMIN_USER_MANAGEMENT)}
/>
<SideBarButton
title="Jobs"
logo={Sync}
isSelected={$page.route.id === AppRoute.ADMIN_JOBS}
on:selected={() => goto(AppRoute.ADMIN_JOBS)}
/>
<SideBarButton
title="Settings"
logo={Cog}
isSelected={$page.route.id === AppRoute.ADMIN_SETTINGS}
on:selected={() => goto(AppRoute.ADMIN_SETTINGS)}
/>
<SideBarButton
title="Server Stats"
logo={Server}
isSelected={$page.route.id === AppRoute.ADMIN_STATS}
on:selected={() => goto(AppRoute.ADMIN_STATS)}
/>
<div class="mb-6 mt-auto">
<StatusBox />
</div>
</section>
<section class="overflow-y-auto ">
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg dark:bg-immich-dark-bg">
<h1 class="text-lg ml-8 mb-4 text-immich-primary dark:text-immich-dark-primary font-medium">
{getPageTitle($page.route.id)}
</h1>
<hr class="dark:border-immich-dark-gray" />
</div>
<section id="setting-content" class="pt-[85px] flex place-content-center">
<section class="w-[800px] pt-5">
<slot />
</section>
</section>
</section>
</section>
</main>

View File

@ -1,5 +1,4 @@
import { redirect } from '@sveltejs/kit';
import { serverApi } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
@ -11,7 +10,5 @@ export const load: PageServerLoad = async ({ parent }) => {
throw redirect(302, '/photos');
}
const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
return { user, allUsers };
throw redirect(302, '/admin/user-management');
};

View File

@ -1,249 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import Sync from 'svelte-material-icons/Sync.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import Server from 'svelte-material-icons/Server.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import UserManagement from '$lib/components/admin-page/user-management.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types';
import { api, ServerStatsResponseDto, UserResponseDto } from '@api';
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
import SettingsPanel from '$lib/components/admin-page/settings/settings-panel.svelte';
import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte';
import RestoreDialoge from '$lib/components/admin-page/restore-dialoge.svelte';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
export let data: PageData;
let selectedUser: UserResponseDto;
let shouldShowEditUserForm = false;
let shouldShowCreateUserForm = false;
let shouldShowInfoPanel = false;
let shouldShowDeleteConfirmDialog = false;
let shouldShowRestoreDialog = false;
let serverStat: ServerStatsResponseDto;
const onButtonClicked = (buttonType: CustomEvent) => {
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
};
onMount(() => {
selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
getServerStats();
});
const onUserCreated = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowCreateUserForm = false;
};
const editUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowEditUserForm = true;
};
const onEditUserSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowEditUserForm = false;
};
const onEditPasswordSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowEditUserForm = false;
shouldShowInfoPanel = true;
};
const deleteUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowDeleteConfirmDialog = true;
};
const onUserDeleteSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const onUserDeleteFail = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const restoreUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowRestoreDialog = true;
};
const onUserRestoreSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const onUserRestoreFail = async () => {
// show fail dialog
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const getServerStats = async () => {
try {
const res = await api.serverInfoApi.getStats();
serverStat = res.data;
} catch (e) {
console.log(e);
}
};
</script>
<svelte:head>
<title>Administration - Immich</title>
</svelte:head>
<NavigationBar user={data.user} />
{#if shouldShowCreateUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
<CreateUserForm on:user-created={onUserCreated} />
</FullScreenModal>
{/if}
{#if shouldShowEditUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm
user={selectedUser}
on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}
/>
</FullScreenModal>
{/if}
{#if shouldShowDeleteConfirmDialog}
<FullScreenModal on:clickOutside={() => (shouldShowDeleteConfirmDialog = false)}>
<DeleteConfirmDialog
user={selectedUser}
on:user-delete-success={onUserDeleteSuccess}
on:user-delete-fail={onUserDeleteFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowRestoreDialog}
<FullScreenModal on:clickOutside={() => (shouldShowRestoreDialog = false)}>
<RestoreDialoge
user={selectedUser}
on:user-restore-success={onUserRestoreSuccess}
on:user-restore-fail={onUserRestoreFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowInfoPanel}
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
<h1 class="font-medium text-immich-primary text-lg mb-4">Password reset success</h1>
<p>
The user's password has been reset to the default <code
class="font-bold bg-gray-200 px-2 py-1 rounded-md text-immich-primary">password</code
>
<br />
Please inform the user, and they will need to change the password at the next log-on.
</p>
<div class="flex w-full">
<button
on:click={() => (shouldShowInfoPanel = false)}
class="mt-6 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Done
</button>
</div>
</div>
</FullScreenModal>
{/if}
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col gap-1">
<SideBarButton
title="Users"
logo={AccountMultipleOutline}
actionType={AdminSideBarSelection.USER_MANAGEMENT}
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
on:selected={onButtonClicked}
/>
<SideBarButton
title="Jobs"
logo={Sync}
actionType={AdminSideBarSelection.JOBS}
isSelected={selectedAction === AdminSideBarSelection.JOBS}
on:selected={onButtonClicked}
/>
<SideBarButton
title="Settings"
logo={Cog}
actionType={AdminSideBarSelection.SETTINGS}
isSelected={selectedAction === AdminSideBarSelection.SETTINGS}
on:selected={onButtonClicked}
/>
<SideBarButton
title="Server Stats"
logo={Server}
actionType={AdminSideBarSelection.STATS}
isSelected={selectedAction === AdminSideBarSelection.STATS}
on:selected={onButtonClicked}
/>
<div class="mb-6 mt-auto">
<StatusBox />
</div>
</section>
<section class="overflow-y-auto relative">
<div id="setting-title" class="pt-10 fixed w-full z-50">
<h1 class="text-lg ml-8 mb-4 text-immich-primary dark:text-immich-dark-primary font-medium">
{selectedAction}
</h1>
<hr class="dark:border-immich-dark-gray" />
</div>
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
<section class="w-[800px] pt-5">
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
<UserManagement
allUsers={data.allUsers}
on:create-user={() => (shouldShowCreateUserForm = true)}
on:edit-user={editUserHandler}
on:delete-user={deleteUserHandler}
on:restore-user={restoreUserHandler}
/>
{/if}
{#if selectedAction === AdminSideBarSelection.JOBS}
<JobsPanel />
{/if}
{#if selectedAction === AdminSideBarSelection.SETTINGS}
<SettingsPanel />
{/if}
{#if selectedAction === AdminSideBarSelection.STATS && serverStat}
<ServerStatsPanel stats={serverStat} allUsers={data.allUsers} />
{/if}
</section>
</section>
</section>
</section>

View File

@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
} else if (!user.isAdmin) {
throw redirect(302, '/photos');
}
};

View File

@ -0,0 +1,11 @@
<script>
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
</script>
<svelte:head>
<title>Jobs Status - Immich</title>
</svelte:head>
<section>
<JobsPanel />
</section>

View File

@ -0,0 +1,17 @@
import { redirect } from '@sveltejs/kit';
import { serverApi } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
} else if (!user.isAdmin) {
throw redirect(302, '/photos');
}
const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
return { user, allUsers };
};

View File

@ -0,0 +1,29 @@
<script lang="ts">
import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte';
import { api, ServerStatsResponseDto } from '@api';
import { onMount } from 'svelte';
import { page } from '$app/stores';
let serverStat: ServerStatsResponseDto;
onMount(() => {
getServerStats();
});
const getServerStats = async () => {
try {
const res = await api.serverInfoApi.getStats();
serverStat = res.data;
} catch (e) {
console.log(e);
}
};
</script>
<svelte:head>
<title>Jobs Status - Immich</title>
</svelte:head>
{#if $page.data.allUsers && serverStat}
<ServerStatsPanel stats={serverStat} allUsers={$page.data.allUsers} />
{/if}

View File

@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
} else if (!user.isAdmin) {
throw redirect(302, '/photos');
}
return { user };
};

View File

@ -0,0 +1,33 @@
<script lang="ts">
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 SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { api, SystemConfigDto } from '@api';
let systemConfig: SystemConfigDto;
const getConfig = async () => {
const { data } = await api.systemConfigApi.getConfig();
systemConfig = data;
return data;
};
</script>
<section class="">
{#await getConfig()}
<LoadingSpinner />
{:then configs}
<SettingAccordion
title="FFmpeg Settings"
subtitle="Manage the resolution and encoding information of the video files"
>
<FFmpegSettings ffmpegConfig={configs.ffmpeg} />
</SettingAccordion>
<SettingAccordion title="OAuth Settings" subtitle="Manage the OAuth integration to Immich app">
<OAuthSettings oauthConfig={configs.oauth} />
</SettingAccordion>
{/await}
</section>

View File

@ -0,0 +1,17 @@
import { redirect } from '@sveltejs/kit';
import { serverApi } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
} else if (!user.isAdmin) {
throw redirect(302, '/photos');
}
const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
return { user, allUsers };
};

View File

@ -0,0 +1,232 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { onMount } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
import RestoreDialogue from '$lib/components/admin-page/restore-dialoge.svelte';
import { page } from '$app/stores';
let allUsers: UserResponseDto[] = [];
let shouldShowEditUserForm = false;
let shouldShowCreateUserForm = false;
let shouldShowInfoPanel = false;
let shouldShowDeleteConfirmDialog = false;
let shouldShowRestoreDialog = false;
let selectedUser: UserResponseDto;
onMount(() => {
allUsers = $page.data.allUsers;
console.log('getting all users', allUsers);
});
const isDeleted = (user: UserResponseDto): boolean => {
return user.deletedAt != null;
};
const locale = navigator.language;
const deleteDateFormat: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric'
};
const getDeleteDate = (user: UserResponseDto): string => {
let deletedAt = new Date(user.deletedAt ? user.deletedAt : Date.now());
deletedAt.setDate(deletedAt.getDate() + 7);
return deletedAt.toLocaleString(locale, deleteDateFormat);
};
const onUserCreated = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowCreateUserForm = false;
};
const editUserHandler = async (user: UserResponseDto) => {
selectedUser = user;
shouldShowEditUserForm = true;
};
const onEditUserSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowEditUserForm = false;
};
const onEditPasswordSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowEditUserForm = false;
shouldShowInfoPanel = true;
};
const deleteUserHandler = async (user: UserResponseDto) => {
selectedUser = user;
shouldShowDeleteConfirmDialog = true;
};
const onUserDeleteSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const onUserDeleteFail = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const restoreUserHandler = async (user: UserResponseDto) => {
selectedUser = user;
shouldShowRestoreDialog = true;
};
const onUserRestoreSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const onUserRestoreFail = async () => {
// show fail dialog
const getAllUsersRes = await api.userApi.getAllUsers(false);
allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
</script>
<svelte:head>
<title>User Management - Immich</title>
</svelte:head>
<section>
{#if shouldShowCreateUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
<CreateUserForm on:user-created={onUserCreated} />
</FullScreenModal>
{/if}
{#if shouldShowEditUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm
user={selectedUser}
on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}
/>
</FullScreenModal>
{/if}
{#if shouldShowDeleteConfirmDialog}
<FullScreenModal on:clickOutside={() => (shouldShowDeleteConfirmDialog = false)}>
<DeleteConfirmDialog
user={selectedUser}
on:user-delete-success={onUserDeleteSuccess}
on:user-delete-fail={onUserDeleteFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowRestoreDialog}
<FullScreenModal on:clickOutside={() => (shouldShowRestoreDialog = false)}>
<RestoreDialogue
user={selectedUser}
on:user-restore-success={onUserRestoreSuccess}
on:user-restore-fail={onUserRestoreFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowInfoPanel}
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
<h1 class="font-medium text-immich-primary text-lg mb-4">Password reset success</h1>
<p>
The user's password has been reset to the default <code
class="font-bold bg-gray-200 px-2 py-1 rounded-md text-immich-primary">password</code
>
<br />
Please inform the user, and they will need to change the password at the next log-on.
</p>
<div class="flex w-full">
<button
on:click={() => (shouldShowInfoPanel = false)}
class="mt-6 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Done
</button>
</div>
</div>
</FullScreenModal>
{/if}
<table class="text-left w-full my-5">
<thead
class="border rounded-md mb-4 bg-gray-50 flex text-immich-primary w-full h-12 dark:bg-immich-dark-gray dark:text-immich-dark-primary dark:border-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th>
<th class="text-center w-1/4 font-medium text-sm">Last name</th>
<th class="text-center w-1/4 font-medium text-sm">Action</th>
</tr>
</thead>
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray"
>
{#if allUsers}
{#each allUsers as user, i}
<tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-fg ${
isDeleted(user)
? 'bg-red-300 dark:bg-red-900'
: i % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">
{#if !isDeleted(user)}
<button
on:click={() => editUserHandler(user)}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
>
<PencilOutline size="16" />
</button>
<button
on:click={() => deleteUserHandler(user)}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
>
<TrashCanOutline size="16" />
</button>
{/if}
{#if isDeleted(user)}
<button
on:click={() => restoreUserHandler(user)}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
title={`scheduled removal on ${getDeleteDate(user)}`}
>
<DeleteRestore size="16" />
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
<button on:click={() => (shouldShowCreateUserForm = true)} class="immich-btn-primary"
>Create user</button
>
</section>