mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01:00
feat(web,server): link/unlink oauth account (#1154)
* feat(web,server): link/unlink oauth account * chore: linting * fix: broken oauth callback * fix: user core bugs * fix: tests * fix: use user response * chore: update docs * feat: prevent the same oauth account from being linked twice * chore: mock logger
This commit is contained in:
parent
ab0a3690f3
commit
7dc12dea1e
27 changed files with 680 additions and 202 deletions
|
@ -26,14 +26,33 @@ Before enabling OAuth in Immich, a new client application needs to be configured
|
||||||
|
|
||||||
The **Sign-in redirect URIs** should include:
|
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`)
|
- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
|
||||||
- Mobile app redirect URL `app.immich:/`
|
- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
|
||||||
|
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
|
||||||
|
|
||||||
:::caution
|
:::info Redirect URIs
|
||||||
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
|
|
||||||
|
Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
|
||||||
|
|
||||||
|
Mobile
|
||||||
|
|
||||||
|
- `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly)
|
||||||
|
|
||||||
|
Localhost
|
||||||
|
|
||||||
|
- `http://localhost:2283/auth/login`
|
||||||
|
- `http://localhost:2283/user-settings`
|
||||||
|
|
||||||
|
Local IP
|
||||||
|
|
||||||
|
- `http://192.168.0.200:2283/auth/login`
|
||||||
|
- `http://192.168.0.200:2283/user-settings`
|
||||||
|
|
||||||
|
Hostname
|
||||||
|
|
||||||
|
- `https://immich.example.com/auth/login`)
|
||||||
|
- `https://immich.example.com/user-settings`)
|
||||||
|
|
||||||
**Authentik example**
|
|
||||||
<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Enable OAuth
|
## Enable OAuth
|
||||||
|
@ -42,7 +61,7 @@ Once you have a new OAuth client application configured, Immich can be configure
|
||||||
|
|
||||||
| 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) |
|
||||||
|
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/OAuthApi.md
generated
BIN
mobile/openapi/doc/OAuthApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/UserResponseDto.md
generated
BIN
mobile/openapi/doc/UserResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/o_auth_api.dart
generated
BIN
mobile/openapi/lib/api/o_auth_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/user_response_dto.dart
generated
BIN
mobile/openapi/lib/model/user_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/o_auth_api_test.dart
generated
BIN
mobile/openapi/test/o_auth_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/user_response_dto_test.dart
generated
BIN
mobile/openapi/test/user_response_dto_test.dart
generated
Binary file not shown.
|
@ -74,9 +74,7 @@ export class AuthService {
|
||||||
throw new BadRequestException('Wrong password');
|
throw new BadRequestException('Wrong password');
|
||||||
}
|
}
|
||||||
|
|
||||||
user.password = newPassword;
|
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
|
||||||
|
|
||||||
return this.userCore.updateUser(authUser, user, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||||
|
|
|
@ -3,8 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthType } from '../../constants/jwt.constant';
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
|
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
||||||
|
import { UserResponseDto } from '../user/response-dto/user-response.dto';
|
||||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||||
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
||||||
import { OAuthService } from './oauth.service';
|
import { OAuthService } from './oauth.service';
|
||||||
|
@ -22,12 +24,26 @@ export class OAuthController {
|
||||||
|
|
||||||
@Post('/callback')
|
@Post('/callback')
|
||||||
public async callback(
|
public async callback(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body(ValidationPipe) dto: OAuthCallbackDto,
|
@Body(ValidationPipe) dto: OAuthCallbackDto,
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const loginResponse = await this.oauthService.callback(authUser, dto);
|
const loginResponse = await this.oauthService.login(dto);
|
||||||
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
|
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
|
||||||
return loginResponse;
|
return loginResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Authenticated()
|
||||||
|
@Post('link')
|
||||||
|
public async link(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Body(ValidationPipe) dto: OAuthCallbackDto,
|
||||||
|
): Promise<UserResponseDto> {
|
||||||
|
return this.oauthService.link(authUser, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Authenticated()
|
||||||
|
@Post('unlink')
|
||||||
|
public async unlink(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
|
||||||
|
return this.oauthService.unlink(authUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { SystemConfig } from '@app/database/entities/system-config.entity';
|
import { SystemConfig } from '@app/database/entities/system-config.entity';
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import { ImmichConfigService } from '@app/immich-config';
|
import { ImmichConfigService } from '@app/immich-config';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { generators, Issuer } from 'openid-client';
|
import { generators, Issuer } from 'openid-client';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
@ -32,6 +32,21 @@ const loginResponse = {
|
||||||
userEmail: 'user@immich.com,',
|
userEmail: 'user@immich.com,',
|
||||||
} as LoginResponseDto;
|
} as LoginResponseDto;
|
||||||
|
|
||||||
|
jest.mock('@nestjs/common', () => {
|
||||||
|
return {
|
||||||
|
...jest.requireActual('@nestjs/common'),
|
||||||
|
Logger: function MockLogger() {
|
||||||
|
Object.assign(this as Logger, {
|
||||||
|
verbose: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
log: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('OAuthService', () => {
|
describe('OAuthService', () => {
|
||||||
let sut: OAuthService;
|
let sut: OAuthService;
|
||||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||||
|
@ -109,9 +124,9 @@ describe('OAuthService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('callback', () => {
|
describe('login', () => {
|
||||||
it('should throw an error if OAuth is not enabled', async () => {
|
it('should throw an error if OAuth is not enabled', async () => {
|
||||||
await expect(sut.callback(authUser, { url: '' })).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow auto registering', async () => {
|
it('should not allow auto registering', async () => {
|
||||||
|
@ -122,10 +137,8 @@ describe('OAuthService', () => {
|
||||||
},
|
},
|
||||||
} as SystemConfig);
|
} as SystemConfig);
|
||||||
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
|
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
|
||||||
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
|
||||||
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
|
|
||||||
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||||
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
|
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
|
@ -139,15 +152,11 @@ describe('OAuthService', () => {
|
||||||
},
|
},
|
||||||
} as SystemConfig);
|
} as SystemConfig);
|
||||||
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
|
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
|
||||||
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
|
||||||
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
|
|
||||||
userRepositoryMock.getByEmail.mockResolvedValue(user);
|
userRepositoryMock.getByEmail.mockResolvedValue(user);
|
||||||
userRepositoryMock.update.mockResolvedValue(user);
|
userRepositoryMock.update.mockResolvedValue(user);
|
||||||
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
|
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
|
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
|
||||||
loginResponse,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||||
|
@ -161,16 +170,12 @@ describe('OAuthService', () => {
|
||||||
},
|
},
|
||||||
} as SystemConfig);
|
} as SystemConfig);
|
||||||
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
|
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
|
||||||
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
|
||||||
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
|
|
||||||
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||||
userRepositoryMock.getAdmin.mockResolvedValue(user);
|
userRepositoryMock.getAdmin.mockResolvedValue(user);
|
||||||
userRepositoryMock.create.mockResolvedValue(user);
|
userRepositoryMock.create.mockResolvedValue(user);
|
||||||
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
|
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
|
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
|
||||||
loginResponse,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||||
expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
|
expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
|
||||||
|
@ -178,6 +183,57 @@ describe('OAuthService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('link', () => {
|
||||||
|
it('should link an account', async () => {
|
||||||
|
immichConfigServiceMock.getConfig.mockResolvedValue({
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
autoRegister: true,
|
||||||
|
},
|
||||||
|
} as SystemConfig);
|
||||||
|
|
||||||
|
userRepositoryMock.update.mockResolvedValue(user);
|
||||||
|
|
||||||
|
await sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' });
|
||||||
|
|
||||||
|
expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: sub });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not link an already linked oauth.sub', async () => {
|
||||||
|
immichConfigServiceMock.getConfig.mockResolvedValue({
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
autoRegister: true,
|
||||||
|
},
|
||||||
|
} as SystemConfig);
|
||||||
|
|
||||||
|
userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
|
||||||
|
|
||||||
|
await expect(sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(userRepositoryMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unlink', () => {
|
||||||
|
it('should unlink an account', async () => {
|
||||||
|
immichConfigServiceMock.getConfig.mockResolvedValue({
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
autoRegister: true,
|
||||||
|
},
|
||||||
|
} as SystemConfig);
|
||||||
|
|
||||||
|
userRepositoryMock.update.mockResolvedValue(user);
|
||||||
|
|
||||||
|
await sut.unlink(authUser);
|
||||||
|
|
||||||
|
expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getLogoutEndpoint', () => {
|
describe('getLogoutEndpoint', () => {
|
||||||
it('should return null if OAuth is not configured', async () => {
|
it('should return null if OAuth is not configured', async () => {
|
||||||
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
|
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'op
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
||||||
|
import { UserResponseDto } from '../user/response-dto/user-response.dto';
|
||||||
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||||
import { UserCore } from '../user/user.core';
|
import { UserCore } from '../user/user.core';
|
||||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||||
|
@ -47,12 +48,8 @@ export class OAuthService {
|
||||||
return { enabled: true, buttonText, url };
|
return { enabled: true, buttonText, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async callback(authUser: AuthUserDto, dto: OAuthCallbackDto): Promise<LoginResponseDto> {
|
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
|
||||||
const redirectUri = dto.url.split('?')[0];
|
const profile = await this.callback(dto.url);
|
||||||
const client = await this.getClient();
|
|
||||||
const params = client.callbackParams(dto.url);
|
|
||||||
const tokens = await client.callback(redirectUri, params, { state: params.state });
|
|
||||||
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
|
||||||
|
|
||||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user = await this.userCore.getByOAuthId(profile.sub);
|
let user = await this.userCore.getByOAuthId(profile.sub);
|
||||||
|
@ -61,7 +58,7 @@ export class OAuthService {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const emailUser = await this.userCore.getByEmail(profile.email);
|
const emailUser = await this.userCore.getByEmail(profile.email);
|
||||||
if (emailUser) {
|
if (emailUser) {
|
||||||
user = await this.userCore.updateUser(authUser, emailUser, { oauthId: profile.sub });
|
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,6 +85,20 @@ export class OAuthService {
|
||||||
return this.immichJwtService.createLoginResponse(user);
|
return this.immichJwtService.createLoginResponse(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||||
|
const { sub: oauthId } = await this.callback(dto.url);
|
||||||
|
const duplicate = await this.userCore.getByOAuthId(oauthId);
|
||||||
|
if (duplicate && duplicate.id !== user.id) {
|
||||||
|
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
||||||
|
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||||
|
}
|
||||||
|
return this.userCore.updateUser(user, user.id, { oauthId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
|
||||||
|
return this.userCore.updateUser(user, user.id, { oauthId: '' });
|
||||||
|
}
|
||||||
|
|
||||||
public async getLogoutEndpoint(): Promise<string | null> {
|
public async getLogoutEndpoint(): Promise<string | null> {
|
||||||
const config = await this.immichConfigService.getConfig();
|
const config = await this.immichConfigService.getConfig();
|
||||||
const { enabled } = config.oauth;
|
const { enabled } = config.oauth;
|
||||||
|
@ -98,6 +109,14 @@ export class OAuthService {
|
||||||
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async callback(url: string): Promise<any> {
|
||||||
|
const redirectUri = url.split('?')[0];
|
||||||
|
const client = await this.getClient();
|
||||||
|
const params = client.callbackParams(url);
|
||||||
|
const tokens = await client.callback(redirectUri, params, { state: params.state });
|
||||||
|
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||||
|
}
|
||||||
|
|
||||||
private async getClient() {
|
private async getClient() {
|
||||||
const config = await this.immichConfigService.getConfig();
|
const config = await this.immichConfigService.getConfig();
|
||||||
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
|
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
|
||||||
|
|
|
@ -10,6 +10,7 @@ export class UserResponseDto {
|
||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
isAdmin!: boolean;
|
isAdmin!: boolean;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
|
oauthId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||||
|
@ -23,5 +24,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
||||||
shouldChangePassword: entity.shouldChangePassword,
|
shouldChangePassword: entity.shouldChangePassword,
|
||||||
isAdmin: entity.isAdmin,
|
isAdmin: entity.isAdmin,
|
||||||
deletedAt: entity.deletedAt,
|
deletedAt: entity.deletedAt,
|
||||||
|
oauthId: entity.oauthId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,27 +25,22 @@ export class UserCore {
|
||||||
return hash(password, salt);
|
return hash(password, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(authUser: AuthUserDto, userToUpdate: UserEntity, data: Partial<UserEntity>): Promise<UserEntity> {
|
async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
|
||||||
if (!authUser.isAdmin && (authUser.id !== userToUpdate.id || userToUpdate.id != data.id)) {
|
if (!(authUser.isAdmin || authUser.id === id)) {
|
||||||
throw new ForbiddenException('You are not allowed to update this user');
|
throw new ForbiddenException('You are not allowed to update this user');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: can this happen? If so we should implement a test case, otherwise remove it (also from DTO)
|
if (dto.isAdmin && authUser.isAdmin && authUser.id !== id) {
|
||||||
if (userToUpdate.isAdmin) {
|
throw new BadRequestException('Admin user exists');
|
||||||
const adminUser = await this.userRepository.getAdmin();
|
|
||||||
if (adminUser && adminUser.id !== userToUpdate.id) {
|
|
||||||
throw new BadRequestException('Admin user exists');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: Partial<UserEntity> = { ...data };
|
if (dto.password) {
|
||||||
if (payload.password) {
|
|
||||||
const salt = await this.generateSalt();
|
const salt = await this.generateSalt();
|
||||||
payload.salt = salt;
|
dto.salt = salt;
|
||||||
payload.password = await this.hashPassword(payload.password, salt);
|
dto.password = await this.hashPassword(dto.password, salt);
|
||||||
}
|
}
|
||||||
return this.userRepository.update(userToUpdate.id, payload);
|
return this.userRepository.update(id, dto);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error(e, 'Failed to update user info');
|
Logger.error(e, 'Failed to update user info');
|
||||||
throw new InternalServerErrorException('Failed to update user info');
|
throw new InternalServerErrorException('Failed to update user info');
|
||||||
|
|
|
@ -141,6 +141,25 @@ describe('UserService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Create user', () => {
|
describe('Create user', () => {
|
||||||
|
it('should let the admin update himself', async () => {
|
||||||
|
const dto = { id: adminUser.id, shouldChangePassword: true, isAdmin: true };
|
||||||
|
|
||||||
|
when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValueOnce(null);
|
||||||
|
when(userRepositoryMock.update).calledWith(adminUser.id, dto).mockResolvedValueOnce(adminUser);
|
||||||
|
|
||||||
|
await sut.updateUser(adminUser, dto);
|
||||||
|
|
||||||
|
expect(userRepositoryMock.update).toHaveBeenCalledWith(adminUser.id, dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not let the another user become an admin', async () => {
|
||||||
|
const dto = { id: immichUser.id, shouldChangePassword: true, isAdmin: true };
|
||||||
|
|
||||||
|
when(userRepositoryMock.get).calledWith(immichUser.id).mockResolvedValueOnce(immichUser);
|
||||||
|
|
||||||
|
await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not create a user if there is no local admin account', async () => {
|
it('should not create a user if there is no local admin account', async () => {
|
||||||
when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);
|
when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
|
|
@ -65,12 +65,12 @@ export class UserService {
|
||||||
return mapUser(createdUser);
|
return mapUser(createdUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
|
async updateUser(authUser: AuthUserDto, dto: UpdateUserDto): Promise<UserResponseDto> {
|
||||||
const user = await this.userCore.get(updateUserDto.id);
|
const user = await this.userCore.get(dto.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
const updatedUser = await this.userCore.updateUser(authUser, user, updateUserDto);
|
const updatedUser = await this.userCore.updateUser(authUser, dto.id, dto);
|
||||||
return mapUser(updatedUser);
|
return mapUser(updatedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,6 +107,7 @@ describe('User', () => {
|
||||||
shouldChangePassword: true,
|
shouldChangePassword: true,
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
oauthId: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: userTwoEmail,
|
email: userTwoEmail,
|
||||||
|
@ -118,6 +119,7 @@ describe('User', () => {
|
||||||
shouldChangePassword: true,
|
shouldChangePassword: true,
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
oauthId: '',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1826,6 +1826,58 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/oauth/link": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "link",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OAuthCallbackDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UserResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"OAuth"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/oauth/unlink": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "unlink",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UserResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"OAuth"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/device-info": {
|
"/device-info": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "createDeviceInfo",
|
"operationId": "createDeviceInfo",
|
||||||
|
@ -2287,6 +2339,9 @@
|
||||||
"deletedAt": {
|
"deletedAt": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"oauthId": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
@ -2297,7 +2352,8 @@
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"profileImagePath",
|
"profileImagePath",
|
||||||
"shouldChangePassword",
|
"shouldChangePassword",
|
||||||
"isAdmin"
|
"isAdmin",
|
||||||
|
"oauthId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"CreateUserDto": {
|
"CreateUserDto": {
|
||||||
|
|
|
@ -24,7 +24,7 @@ export class UserEntity {
|
||||||
@Column({ default: '', select: false })
|
@Column({ default: '', select: false })
|
||||||
salt?: string;
|
salt?: string;
|
||||||
|
|
||||||
@Column({ default: '', select: false })
|
@Column({ default: '' })
|
||||||
oauthId!: string;
|
oauthId!: string;
|
||||||
|
|
||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
|
|
127
web/src/api/open-api/api.ts
generated
127
web/src/api/open-api/api.ts
generated
|
@ -1951,6 +1951,12 @@ export interface UserResponseDto {
|
||||||
* @memberof UserResponseDto
|
* @memberof UserResponseDto
|
||||||
*/
|
*/
|
||||||
'deletedAt'?: string;
|
'deletedAt'?: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UserResponseDto
|
||||||
|
*/
|
||||||
|
'oauthId': string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -5059,6 +5065,70 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
|
||||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
|
localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
link: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'oAuthCallbackDto' is not null or undefined
|
||||||
|
assertParamExists('link', 'oAuthCallbackDto', oAuthCallbackDto)
|
||||||
|
const localVarPath = `/oauth/link`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
unlink: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/oauth/unlink`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: toPathString(localVarUrlObj),
|
url: toPathString(localVarUrlObj),
|
||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
|
@ -5094,6 +5164,25 @@ export const OAuthApiFp = function(configuration?: Configuration) {
|
||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.link(oAuthCallbackDto, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async unlink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.unlink(options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5122,6 +5211,23 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
|
||||||
generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
|
generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
|
||||||
return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
|
return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
link(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<UserResponseDto> {
|
||||||
|
return localVarFp.link(oAuthCallbackDto, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
unlink(options?: any): AxiosPromise<UserResponseDto> {
|
||||||
|
return localVarFp.unlink(options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5153,6 +5259,27 @@ export class OAuthApi extends BaseAPI {
|
||||||
public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
|
public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
|
||||||
return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
|
return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {OAuthCallbackDto} oAuthCallbackDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof OAuthApi
|
||||||
|
*/
|
||||||
|
public link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
|
||||||
|
return OAuthApiFp(this.configuration).link(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof OAuthApi
|
||||||
|
*/
|
||||||
|
public unlink(options?: AxiosRequestConfig) {
|
||||||
|
return OAuthApiFp(this.configuration).unlink(options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { AxiosError, AxiosPromise } from 'axios';
|
||||||
|
import { api } from './api';
|
||||||
|
import { UserResponseDto } from './open-api';
|
||||||
|
|
||||||
const _basePath = '/api';
|
const _basePath = '/api';
|
||||||
|
|
||||||
export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) {
|
export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) {
|
||||||
|
@ -9,3 +13,26 @@ export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean)
|
||||||
|
|
||||||
return urlObj.href;
|
return urlObj.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiError = AxiosError<{ message: string }>;
|
||||||
|
|
||||||
|
export const oauth = {
|
||||||
|
isCallback: (location: Location) => {
|
||||||
|
const search = location.search;
|
||||||
|
return search.includes('code=') || search.includes('error=');
|
||||||
|
},
|
||||||
|
getConfig: (location: Location) => {
|
||||||
|
const redirectUri = location.href.split('?')[0];
|
||||||
|
console.log(`OAuth Redirect URI: ${redirectUri}`);
|
||||||
|
return api.oauthApi.generateConfig({ redirectUri });
|
||||||
|
},
|
||||||
|
login: (location: Location) => {
|
||||||
|
return api.oauthApi.callback({ url: location.href });
|
||||||
|
},
|
||||||
|
link: (location: Location): AxiosPromise<UserResponseDto> => {
|
||||||
|
return api.oauthApi.link({ url: location.href });
|
||||||
|
},
|
||||||
|
unlink: () => {
|
||||||
|
return api.oauthApi.unlink();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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, OAuthConfigResponseDto } from '@api';
|
import { api, oauth, OAuthConfigResponseDto } from '@api';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
|
||||||
let error: string;
|
let error: string;
|
||||||
|
@ -14,11 +14,10 @@
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const search = window.location.search;
|
if (oauth.isCallback(window.location)) {
|
||||||
if (search.includes('code=') || search.includes('error=')) {
|
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
await api.oauthApi.callback({ url: window.location.href });
|
await oauth.login(window.location);
|
||||||
dispatch('success');
|
dispatch('success');
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -29,9 +28,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const redirectUri = window.location.href.split('?')[0];
|
const { data } = await oauth.getConfig(window.location);
|
||||||
console.log(`OAuth Redirect URI: ${redirectUri}`);
|
|
||||||
const { data } = await api.oauthApi.generateConfig({ redirectUri });
|
|
||||||
oauthConfig = data;
|
oauthConfig = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error [login-form] [oauth.generateConfig]', e);
|
console.error('Error [login-form] [oauth.generateConfig]', e);
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
|
import { api, ApiError } from '@api';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import SettingInputField, {
|
||||||
|
SettingInputFieldType
|
||||||
|
} from '../admin-page/settings/setting-input-field.svelte';
|
||||||
|
|
||||||
|
let password = '';
|
||||||
|
let newPassword = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
try {
|
||||||
|
await api.authenticationApi.changePassword({
|
||||||
|
password,
|
||||||
|
newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Updated password',
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
|
||||||
|
password = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error [user-profile] [changePassword]', error);
|
||||||
|
notificationController.show({
|
||||||
|
message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
|
||||||
|
type: NotificationType.Error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
<div in:fade={{ duration: 500 }}>
|
||||||
|
<form autocomplete="off" on:submit|preventDefault>
|
||||||
|
<div class="flex flex-col gap-4 ml-4 mt-4">
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.PASSWORD}
|
||||||
|
label="Password"
|
||||||
|
bind:value={password}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.PASSWORD}
|
||||||
|
label="New password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.PASSWORD}
|
||||||
|
label="Confirm password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!(password && newPassword && newPassword === confirmPassword)}
|
||||||
|
on:click={() => handleChangePassword()}
|
||||||
|
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
|
@ -0,0 +1,86 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { oauth, OAuthConfigResponseDto, UserResponseDto } from '@api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { handleError } from '../../utils/handle-error';
|
||||||
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '../shared-components/notification/notification';
|
||||||
|
|
||||||
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
|
let config: OAuthConfigResponseDto = { enabled: false };
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (oauth.isCallback(window.location)) {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const { data } = await oauth.link(window.location);
|
||||||
|
user = data;
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Linked OAuth account',
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Unable to link OAuth account');
|
||||||
|
} finally {
|
||||||
|
goto('?open=oauth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await oauth.getConfig(window.location);
|
||||||
|
config = data;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Unable to load OAuth config');
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUnlink = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await oauth.unlink();
|
||||||
|
user = data;
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Unlinked OAuth account',
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Unable to unlink account');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
<div in:fade={{ duration: 500 }}>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex place-items-center place-content-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{:else if config.enabled}
|
||||||
|
{#if user.oauthId}
|
||||||
|
<button
|
||||||
|
on:click={() => handleUnlink()}
|
||||||
|
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>Unlink OAuth
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a href={config.url}>
|
||||||
|
<button
|
||||||
|
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>Link to OAuth</button
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
|
import { api, UserResponseDto } from '@api';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import SettingInputField, {
|
||||||
|
SettingInputFieldType
|
||||||
|
} from '../admin-page/settings/setting-input-field.svelte';
|
||||||
|
|
||||||
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.userApi.updateUser({
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(user, data);
|
||||||
|
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Saved profile',
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error [user-profile] [updateProfile]', error);
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Unable to save profile',
|
||||||
|
type: NotificationType.Error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
<div in:fade={{ duration: 500 }}>
|
||||||
|
<form autocomplete="off" on:submit|preventDefault>
|
||||||
|
<div class="flex flex-col gap-4 ml-4 mt-4">
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label="User ID"
|
||||||
|
bind:value={user.id}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label="Email"
|
||||||
|
bind:value={user.email}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label="First name"
|
||||||
|
bind:value={user.firstName}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label="Last name"
|
||||||
|
bind:value={user.lastName}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
on:click={() => handleSaveProfile()}
|
||||||
|
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
|
@ -1,156 +1,43 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { page } from '$app/stores';
|
||||||
notificationController,
|
import { oauth, UserResponseDto } from '@api';
|
||||||
NotificationType
|
import { onMount } from 'svelte';
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
|
||||||
import { api, UserResponseDto } from '@api';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
|
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
|
||||||
import SettingInputField, {
|
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||||
SettingInputFieldType
|
import OAuthSettings from './oauth-settings.svelte';
|
||||||
} from '../admin-page/settings/setting-input-field.svelte';
|
import UserProfileSettings from './user-profile-settings.svelte';
|
||||||
|
|
||||||
type ApiError = AxiosError<{ message: string }>;
|
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
let oauthEnabled = false;
|
||||||
|
let oauthOpen = false;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
oauthOpen = oauth.isCallback(window.location);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.userApi.updateUser({
|
const { data } = await oauth.getConfig(window.location);
|
||||||
id: user.id,
|
oauthEnabled = data.enabled;
|
||||||
firstName: user.firstName,
|
} catch {
|
||||||
lastName: user.lastName
|
// noop
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(user, data);
|
|
||||||
|
|
||||||
notificationController.show({
|
|
||||||
message: 'Saved profile',
|
|
||||||
type: NotificationType.Info
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error [user-profile] [updateProfile]', error);
|
|
||||||
notificationController.show({
|
|
||||||
message: 'Unable to save profile',
|
|
||||||
type: NotificationType.Error
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
let password = '';
|
|
||||||
let newPassword = '';
|
|
||||||
let confirmPassword = '';
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
try {
|
|
||||||
await api.authenticationApi.changePassword({
|
|
||||||
password,
|
|
||||||
newPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationController.show({
|
|
||||||
message: 'Updated password',
|
|
||||||
type: NotificationType.Info
|
|
||||||
});
|
|
||||||
|
|
||||||
password = '';
|
|
||||||
newPassword = '';
|
|
||||||
confirmPassword = '';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error [user-profile] [changePassword]', error);
|
|
||||||
notificationController.show({
|
|
||||||
message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
|
|
||||||
type: NotificationType.Error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SettingAccordion title="User Profile" subtitle="View and manage your profile">
|
<SettingAccordion title="User Profile" subtitle="View and manage your profile">
|
||||||
<section class="my-4">
|
<UserProfileSettings {user} />
|
||||||
<div in:fade={{ duration: 500 }}>
|
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
|
||||||
<div class="flex flex-col gap-4 ml-4 mt-4">
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label="User ID"
|
|
||||||
bind:value={user.id}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label="Email"
|
|
||||||
bind:value={user.email}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label="First name"
|
|
||||||
bind:value={user.firstName}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label="Last name"
|
|
||||||
bind:value={user.lastName}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
on:click={() => handleSaveProfile()}
|
|
||||||
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion title="Password" subtitle="Change your password">
|
<SettingAccordion title="Password" subtitle="Change your password">
|
||||||
<section class="my-4">
|
<ChangePasswordSettings />
|
||||||
<div in:fade={{ duration: 500 }}>
|
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
|
||||||
<div class="flex flex-col gap-4 ml-4 mt-4">
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.PASSWORD}
|
|
||||||
label="Password"
|
|
||||||
bind:value={password}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.PASSWORD}
|
|
||||||
label="New password"
|
|
||||||
bind:value={newPassword}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingInputField
|
|
||||||
inputType={SettingInputFieldType.PASSWORD}
|
|
||||||
label="Confirm password"
|
|
||||||
bind:value={confirmPassword}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!(password && newPassword && newPassword === confirmPassword)}
|
|
||||||
on:click={() => handleChangePassword()}
|
|
||||||
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
{#if oauthEnabled}
|
||||||
|
<SettingAccordion
|
||||||
|
title="OAuth"
|
||||||
|
subtitle="Manage your linked account"
|
||||||
|
isOpen={oauthOpen || $page.url.searchParams.get('open') === 'oauth'}
|
||||||
|
>
|
||||||
|
<OAuthSettings {user} />
|
||||||
|
</SettingAccordion>
|
||||||
|
{/if}
|
||||||
|
|
13
web/src/lib/utils/handle-error.ts
Normal file
13
web/src/lib/utils/handle-error.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { ApiError } from '../../api';
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '../components/shared-components/notification/notification';
|
||||||
|
|
||||||
|
export function handleError(error: unknown, message: string) {
|
||||||
|
console.error(`[handleError]: ${message}`, error);
|
||||||
|
notificationController.show({
|
||||||
|
message: (error as ApiError)?.response?.data?.message || message,
|
||||||
|
type: NotificationType.Error
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue