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 877 additions and 205 deletions

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