1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
Jason Rasmussen 2022-12-26 10:35:52 -05:00 committed by GitHub
parent ab0a3690f3
commit 7dc12dea1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 680 additions and 202 deletions

View File

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

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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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

View 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
});
}