OAuth providers

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-11-20 17:14:49 +01:00
parent cff927d477
commit f6eb87fa2b
31 changed files with 2627 additions and 60 deletions
+99
View File
@@ -1815,6 +1815,99 @@ export class API {
}
};
/**
* oauthProvider is the API for OAuth Provider related operations.
*/
oauthProvider = {
/**
* Get all OAuth Providers using pagination.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAll: async (options, companyID) => {
return await getJSON(
this.getPath(`/oauth-provider?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
);
},
/**
* Get an OAuth Provider by its ID.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
getByID: async (id) => {
return await getJSON(this.getPath(`/oauth-provider/${id}`));
},
/**
* Create a new OAuth Provider.
*
* @param {Object} provider
* @param {string} provider.name
* @param {string} provider.clientID
* @param {string} provider.clientSecret
* @param {string} provider.authURL
* @param {string} provider.tokenURL
* @param {string} provider.scopes
* @param {string} provider.companyID
* @returns {Promise<ApiResponse>}
*/
create: async (provider) => {
return await postJSON(this.getPath('/oauth-provider'), provider);
},
/**
* Update an OAuth Provider.
*
* @param {Object} provider
* @param {string} provider.id
* @param {string} provider.name
* @param {string} provider.clientID
* @param {string} provider.clientSecret
* @param {string} provider.authURL
* @param {string} provider.tokenURL
* @param {string} provider.scopes
* @param {string} provider.companyID
* @returns {Promise<ApiResponse>}
*/
update: async (provider) => {
return await patchJSON(this.getPath(`/oauth-provider/${provider.id}`), provider);
},
/**
* Delete an OAuth Provider.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
delete: async (id) => {
return await deleteJSON(this.getPath(`/oauth-provider/${id}`));
},
/**
* Get the authorization URL for an OAuth Provider.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
getAuthorizationURL: async (id) => {
return await getJSON(this.getPath(`/oauth-authorize/${id}`));
},
/**
* Remove authorization tokens from an OAuth Provider.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
removeAuthorization: async (id) => {
return await postJSON(this.getPath(`/oauth-provider/${id}/remove-authorization`), {});
}
};
/**
* user is the API for user related operations - these actions also affect the user's sessions
*/
@@ -2501,6 +2594,7 @@ export class API {
* @param {string} sender.customField2
* @param {string} sender.customField3
* @param {string} sender.customField4
* @param {string} sender.oauthProviderID
* @param {string} sender.requestMethod
* @param {string} sender.requestURL
* @param {APISenderHeader[]} sender.requestHeaders
@@ -2518,6 +2612,7 @@ export class API {
customField2,
customField3,
customField4,
oauthProviderID,
requestMethod,
requestURL,
requestHeaders,
@@ -2539,6 +2634,7 @@ export class API {
customField2: customField2,
customField3: customField3,
customField4: customField4,
oauthProviderID: oauthProviderID,
requestMethod: requestMethod,
requestURL: requestURL,
requestHeaders: requestHeaders,
@@ -2561,6 +2657,7 @@ export class API {
* @param {string} sender.customField2
* @param {string} sender.customField3
* @param {string} sender.customField4
* @param {string} sender.oauthProviderID
* @param {string} sender.requestMethod
* @param {string} sender.requestURL
* @param {APISenderHeader[]} sender.requestHeaders
@@ -2578,6 +2675,7 @@ export class API {
customField2,
customField3,
customField4,
oauthProviderID,
requestMethod,
requestURL,
requestHeaders,
@@ -2602,6 +2700,7 @@ export class API {
customField2: customField2,
customField3: customField3,
customField4: customField4,
oauthProviderID: oauthProviderID,
requestMethod: requestMethod,
requestURL: requestURL,
requestHeaders: requestHeaders,
@@ -125,6 +125,11 @@
api_senders: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
`,
oauth_providers: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
`,
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
@@ -149,7 +154,8 @@
'/email/': 'emails_overview',
'/attachment/': 'attachments',
'/smtp-configuration/': 'smtp_configurations',
'/api-sender/': 'api_senders'
'/api-sender/': 'api_senders',
'/oauth-provider/': 'oauth_providers'
};
return icons[iconMap[route] || 'dashboard']; // fallback to dashboard if route not found
@@ -67,6 +67,10 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>`,
oauth_providers: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>`,
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>`,
@@ -123,6 +127,7 @@
'/attachment/': 'attachments',
'/smtp-configuration/': 'smtp_configurations',
'/api-sender/': 'api_senders',
'/oauth-provider/': 'oauth_providers',
'/profile/': 'profile',
'/sessions/': 'sessions',
'/user/': 'users',
@@ -10,6 +10,7 @@
export let confirm = false;
export let confirmWord = 'confirm';
export let permanent = true;
export let actionMessage = '';
let confirmText = '';
@@ -47,11 +48,19 @@
<h3 class="text-lg font-medium text-gray-900">Delete {type}</h3>
-->
<p class="mt-2 text-gray-600 dark:text-gray-400">
Are you sure you want to delete
{#if name?.length > 30}
<br />
{#if actionMessage}
{actionMessage}
{#if name?.length > 30}
<br />
{/if}
<span class="font-medium text-gray-900 dark:text-gray-300">"{name}"</span>?
{:else}
Are you sure you want to delete
{#if name?.length > 30}
<br />
{/if}
<span class="font-medium text-gray-900 dark:text-gray-300">"{name}"</span>?
{/if}
<span class="font-medium text-gray-900 dark:text-gray-300">"{name}"</span>?
</p>
</div>
+11 -1
View File
@@ -81,6 +81,10 @@ export const route = {
label: 'API Senders',
route: '/api-sender/'
},
oauthProviders: {
label: 'OAuth',
route: '/oauth-provider/'
},
allowDeny: {
label: 'Filters',
route: '/filter/'
@@ -122,7 +126,13 @@ export const menu = [
{
label: 'Emails',
type: 'submenu',
items: [route.emails, route.attachments, route.smtpConfigurations, route.apiSenders]
items: [
route.emails,
route.attachments,
route.smtpConfigurations,
route.apiSenders,
route.oauthProviders
]
}
];
+92 -36
View File
@@ -11,27 +11,30 @@
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import { AppStateService } from '$lib/service/appState';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import Modal from '$lib/components/Modal.svelte';
import TextareaField from '$lib/components/TextareaField.svelte';
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
import { getModalText } from '$lib/utils/common';
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TextareaField from '$lib/components/TextareaField.svelte';
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
import { addToast } from '$lib/store/toast';
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
import { BiMap } from '$lib/utils/maps';
import { fetchAllRows } from '$lib/utils/api-utils';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import VimToggle from '$lib/components/editor/VimToggle.svelte';
// services
@@ -40,6 +43,7 @@
// data
let form = null;
let contextCompanyID = null;
let oauthProviderMap = new BiMap({});
let formValues = {
name: '',
companyID: '',
@@ -48,6 +52,7 @@
customField2: '',
customField3: '',
customField4: '',
oauthProvider: null,
requestMethod: '',
requestURL: '',
requestHeaders: '',
@@ -87,6 +92,15 @@
contextCompanyID = appStateService.getContext().companyID;
formValues.companyID = contextCompanyID;
}
// load oauth providers
(async () => {
const oauthProviders = await fetchAllRows((options) => {
return api.oauthProvider.getAll(options, contextCompanyID);
});
oauthProviderMap = BiMap.FromArrayOfObjects(oauthProviders);
})();
refreshConfigurations();
tableURLParams.onChange(refreshConfigurations);
(async () => {
@@ -148,7 +162,10 @@
const onClickCreate = async () => {
try {
const res = await api.apiSender.create(formValues);
const res = await api.apiSender.create({
...formValues,
oauthProviderID: oauthProviderMap.byValueOrNull(formValues.oauthProvider)
});
if (!res.success) {
modalError = res.error;
return;
@@ -165,7 +182,10 @@
const onClickUpdate = async (saveOnly = false) => {
try {
const res = await api.apiSender.update(formValues);
const res = await api.apiSender.update({
...formValues,
oauthProviderID: oauthProviderMap.byValueOrNull(formValues.oauthProvider)
});
if (!res.success) {
modalError = res.error;
throw res.error;
@@ -177,6 +197,7 @@
}
refreshConfigurations();
} catch (err) {
addToast('Failed to update API sender', 'Error');
console.error('failed to update API sender:', err);
}
};
@@ -259,6 +280,7 @@
const assignAPISender = (apiSender) => {
formValues = apiSender;
formValues.oauthProvider = oauthProviderMap.byKey(apiSender.oauthProviderID);
};
const closeEditModal = () => {
@@ -418,7 +440,17 @@
maxLength={255}
optional={true}
toolTipText="Use as {'{{.APIKey}}'}"
placeholder="S3C-R37-AP1-K3Y">API Key</TextField
placeholder="your-api-key">API Key</TextField
>
</div>
<div>
<TextFieldSelect
id="oauthProvider"
bind:value={formValues.oauthProvider}
options={oauthProviderMap.values()}
optional={true}
toolTipText="OAuth provider for token-based authentication. Access token available as {'{{.OAuthAccessToken}}'}"
>OAuth Provider (Optional)</TextFieldSelect
>
</div>
</div>
@@ -589,7 +621,9 @@ X-Custom-Header: Hello Friend"
</Modal>
<Modal headerText="API Sender Test Results" visible={isTestModalVisible} onClose={closeTestModal}>
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6 select-text">
<div
class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6 select-text overflow-x-hidden"
>
{#if !testResponse.error}
<!-- Successful Test -->
<div class="mb-6 pt-4 pb-2 border-b border-gray-200 dark:border-gray-600 w-full">
@@ -599,7 +633,7 @@ X-Custom-Header: Hello Friend"
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
>
<div class="font-medium">
<div class="font-medium break-all">
{testResponse.apiSender?.requestMethod}
{testResponse?.request?.url}
</div>
@@ -611,21 +645,28 @@ X-Custom-Header: Hello Friend"
Request Headers
</h3>
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{headersToString(
testResponse.request?.headers
) || 'No headers'}</pre>
<div
class="text-xs whitespace-pre-wrap break-all max-h-60 overflow-y-auto font-mono"
style="word-break: break-all; overflow-wrap: anywhere;"
>
{headersToString(testResponse.request?.headers) || 'No headers'}
</div>
</div>
</div>
<div class="pt-4 pb-2 w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Request Body</h3>
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-80">{testResponse.request
?.body || 'Empty body'}</pre>
<div
class="text-xs whitespace-pre-wrap break-all max-h-80 overflow-y-auto font-mono"
style="word-break: break-all; overflow-wrap: anywhere;"
>
{testResponse.request?.body || 'Empty body'}
</div>
</div>
</div>
<!-- response -->
@@ -635,7 +676,7 @@ X-Custom-Header: Hello Friend"
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="text-sm font-medium mb-1">Received:</div>
<div
@@ -648,7 +689,7 @@ X-Custom-Header: Hello Friend"
</div>
</div>
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="text-sm font-medium mb-1">Expected:</div>
<div>{testResponse.apiSender?.expectedResponseStatusCode}</div>
@@ -662,19 +703,26 @@ X-Custom-Header: Hello Friend"
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="text-sm font-medium mb-1">Received:</div>
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{headersToString(
testResponse.response?.headers
) || 'No headers'}</pre>
<div
class="text-xs whitespace-pre-wrap break-all max-h-60 overflow-y-auto font-mono"
style="word-break: break-all; overflow-wrap: anywhere;"
>
{headersToString(testResponse.response?.headers) || 'No headers'}
</div>
</div>
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="text-sm font-medium mb-1">Expected to contain:</div>
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{testResponse
.apiSender?.expectedResponseHeaders || 'No validation specified'}</pre>
<div
class="text-xs whitespace-pre-wrap break-all max-h-60 overflow-y-auto font-mono"
style="word-break: break-all; overflow-wrap: anywhere;"
>
{testResponse.apiSender?.expectedResponseHeaders || 'No validation specified'}
</div>
</div>
</div>
</div>
@@ -683,18 +731,26 @@ X-Custom-Header: Hello Friend"
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Response Body</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="text-sm font-medium mb-1">Received:</div>
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-80">{testResponse
.response?.body || 'Empty response'}</pre>
<div
class="text-xs whitespace-pre-wrap break-all max-h-80 overflow-y-auto font-mono"
style="word-break: break-all; overflow-wrap: anywhere;"
>
{testResponse.response?.body || 'Empty response'}
</div>
</div>
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="text-sm font-medium mb-1">Expected to contain:</div>
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-80">{testResponse
.apiSender?.expectedResponseBody || 'No validation specified'}</pre>
<div
class="text-xs whitespace-pre-wrap break-all max-h-80 overflow-y-auto font-mono"
style="word-break: break-all; overflow-wrap: anywhere;"
>
{testResponse.apiSender?.expectedResponseBody || 'No validation specified'}
</div>
</div>
</div>
</div>
@@ -705,9 +761,9 @@ X-Custom-Header: Hello Friend"
Request Details
</h3>
<div
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
>
<div class="font-medium">
<div class="font-medium break-all">
{testResponse.apiSender?.requestMethod}
{testResponse.apiSender?.requestURL}
</div>
@@ -715,8 +771,8 @@ X-Custom-Header: Hello Friend"
</div>
<div class="pt-4 pb-2 w-full">
<h3 class="text-base font-medium text-red-600 mb-3">Error</h3>
<div class="p-4 bg-red-50 rounded-md border border-red-200">
<div class="text-red-600 whitespace-pre-wrap">{testResponse.error}</div>
<div class="p-4 bg-red-50 rounded-md border border-red-200 overflow-hidden">
<div class="text-red-600 whitespace-pre-wrap break-words">{testResponse.error}</div>
</div>
</div>
{/if}
@@ -117,8 +117,8 @@
refreshApiSenders(),
refreshPages(),
refreshProxies(),
getCampaignTemplates(),
refreshIdentifiers()
refreshIdentifiers(),
getCampaignTemplates()
]);
tableURLParams.onChange(refreshCampaignTemplates);
const editID = $page.url.searchParams.get('edit');
@@ -395,7 +395,7 @@
const closeModal = () => {
isModalVisible = false;
form.reset();
form?.reset();
formValues = {
id: null,
templateType: 'Email',
@@ -413,8 +413,6 @@
stateIdentifier: 'session',
urlPath: ''
};
modalError = '';
showAdvancedOptions = false;
};
/** @param {string} id */
@@ -463,7 +461,7 @@
if (template.smtpConfigurationID) {
formValues.templateType = 'Email';
} else {
formValues.templateType = 'API Sender';
formValues.templateType = 'External API';
}
formValues.domain = domainMap.byKey(template.domainID);
formValues.email = emailMap.byKey(template.emailID);
@@ -0,0 +1,552 @@
<script>
import { page } from '$app/stores';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import PasswordField from '$lib/components/PasswordField.svelte';
import { AppStateService } from '$lib/service/appState';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import Modal from '$lib/components/Modal.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { getModalText } from '$lib/utils/common';
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import SelectSquare from '$lib/components/SelectSquare.svelte';
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
import TextareaField from '$lib/components/TextareaField.svelte';
// services
const appStateService = AppStateService.instance;
// data
let form = null;
let formValues = {
id: null,
name: null,
clientID: null,
clientSecret: null,
authURL: null,
tokenURL: null,
scopes: null
};
let providers = [];
let providersHasNextPage = false;
let formError = '';
let contextCompanyID = null;
const tableURLParams = newTableURLParams();
let isModalVisible = false;
let isProviderTableLoading = false;
let isSubmitting = false;
let modalMode = null;
let modalText = '';
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
let isRemoveAuthAlertVisible = false;
let removeAuthValues = {
id: null,
name: null
};
$: {
modalText = getModalText('OAuth', modalMode);
}
// hooks
onMount(() => {
if (appStateService.getContext()) {
contextCompanyID = appStateService.getContext().companyID;
}
refreshProviders();
tableURLParams.onChange(refreshProviders);
// listen for oauth callback messages from popup window
const handleMessage = (event) => {
console.log('received message:', event.data, 'from origin:', event.origin);
// verify message is from our origin or localhost (for dev with vite proxy)
const isValidOrigin =
event.origin === window.location.origin ||
(window.location.hostname === 'localhost' &&
new URL(event.origin).hostname === 'localhost');
if (!isValidOrigin) {
console.log('message origin does not match, ignoring');
return;
}
// handle oauth callback result
if (event.data && event.data.type === 'oauth-callback') {
console.log('oauth callback message received with status:', event.data.status);
if (event.data.status === 'success') {
addToast('OAuth authorization successful!', 'Success');
refreshProviders();
} else if (event.data.status === 'error') {
addToast('OAuth authorization failed', 'Error');
}
}
};
window.addEventListener('message', handleMessage);
console.log('message listener added for oauth callbacks');
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) {
await openUpdateModal(editID);
}
})();
return () => {
tableURLParams.unsubscribe();
window.removeEventListener('message', handleMessage);
};
});
// component logic
const refreshProviders = async () => {
try {
isProviderTableLoading = true;
const data = await getProviders();
providers = data.rows;
providersHasNextPage = data.hasNextPage;
} catch (e) {
addToast('Failed to get OAuth providers', 'Error');
console.error(e);
} finally {
isProviderTableLoading = false;
}
};
/**
* Gets a provider by ID
* @param {string} id
*/
const getProvider = async (id) => {
try {
showIsLoading();
const res = await api.oauthProvider.getByID(id);
if (res.success) {
return res.data;
} else {
throw res.error;
}
} catch (e) {
addToast('Failed to get OAuth provider', 'Error');
console.error('failed to get OAuth provider', e);
} finally {
hideIsLoading();
}
};
const getProviders = async () => {
try {
const res = await api.oauthProvider.getAll(tableURLParams, contextCompanyID);
if (!res.success) {
throw res.error;
}
return res.data;
} catch (e) {
addToast('Failed to get OAuth providers', 'Error');
console.error('failed to get OAuth providers', e);
}
return [];
};
const onClickSubmit = async () => {
try {
isSubmitting = true;
if (modalMode === 'create' || modalMode === 'copy') {
await create();
return;
} else {
await update();
return;
}
} finally {
isSubmitting = false;
}
};
const create = async () => {
formError = '';
try {
const res = await api.oauthProvider.create({
name: formValues.name,
clientID: formValues.clientID,
clientSecret: formValues.clientSecret,
authURL: formValues.authURL,
tokenURL: formValues.tokenURL,
scopes: formValues.scopes,
companyID: contextCompanyID
});
if (!res.success) {
formError = res.error;
return;
}
addToast('Created OAuth provider', 'Success');
closeModal();
} catch (err) {
addToast('Failed to create OAuth provider', 'Error');
console.error('failed to create OAuth provider:', err);
}
refreshProviders();
};
const update = async () => {
formError = '';
try {
const res = await api.oauthProvider.update({
id: formValues.id,
name: formValues.name,
clientID: formValues.clientID,
clientSecret: formValues.clientSecret,
authURL: formValues.authURL,
tokenURL: formValues.tokenURL,
scopes: formValues.scopes,
companyID: formValues.companyID
});
if (res.success) {
addToast('Updated OAuth provider', 'Success');
closeModal();
} else {
formError = res.error;
}
} catch (e) {
addToast('Failed to update OAuth provider', 'Error');
console.error('failed to update OAuth provider', e);
}
refreshProviders();
};
const openDeleteAlert = async (provider) => {
isDeleteAlertVisible = true;
deleteValues.id = provider.id;
deleteValues.name = provider.name;
};
/**
* Deletes an OAuth provider
* @param {string} id
*/
const onClickDelete = async (id) => {
const action = api.oauthProvider.delete(id);
action
.then((res) => {
if (res.success) {
refreshProviders();
return;
}
throw res.error;
})
.catch((e) => {
console.error('failed to delete oauth provider', e);
});
return action;
};
const openCreateModal = () => {
modalMode = 'create';
formError = '';
formValues = {
id: null,
name: null,
clientID: null,
clientSecret: null,
authURL: null,
tokenURL: null,
scopes: null
};
isModalVisible = true;
};
const openUpdateModal = async (id) => {
modalMode = 'update';
formError = '';
const provider = await getProvider(id);
if (!provider) {
return;
}
formValues = {
id: provider.id,
name: provider.name,
clientID: provider.clientID,
clientSecret: provider.clientSecret,
authURL: provider.authURL,
tokenURL: provider.tokenURL,
scopes: provider.scopes,
companyID: provider.companyID
};
isModalVisible = true;
};
const openCopyModal = async (id) => {
modalMode = 'copy';
formError = '';
const provider = await getProvider(id);
if (!provider) {
return;
}
formValues = {
id: null,
name: provider.name + ' (copy)',
clientID: provider.clientID,
clientSecret: provider.clientSecret,
authURL: provider.authURL,
tokenURL: provider.tokenURL,
scopes: provider.scopes
};
isModalVisible = true;
};
const openRemoveAuthAlert = async (provider) => {
isRemoveAuthAlertVisible = true;
removeAuthValues.id = provider.id;
removeAuthValues.name = provider.name;
};
/**
* Removes authorization from an OAuth provider
* @param {string} id
*/
const onClickRemoveAuth = async (id) => {
const action = api.oauthProvider.removeAuthorization(id);
action
.then((res) => {
if (res.success) {
addToast('Removed authorization from OAuth provider', 'Success');
refreshProviders();
return;
}
throw res.error;
})
.catch((e) => {
addToast('Failed to remove authorization', 'Error');
console.error('failed to remove authorization from oauth provider', e);
});
return action;
};
const closeModal = () => {
isModalVisible = false;
form?.reset();
};
const onClickAuthorize = async (id) => {
try {
showIsLoading();
const res = await api.oauthProvider.getAuthorizationURL(id);
if (res.success && res.data.authorizationURL) {
// open authorization url in popup window
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const popup = window.open(
res.data.authorizationURL,
'OAuth Authorization',
`width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes`
);
if (!popup) {
addToast('Failed to open authorization window. Please allow popups.', 'Error');
}
} else {
throw res.error || 'No authorization URL returned';
}
} catch (e) {
addToast('Failed to get authorization URL', 'Error');
console.error('failed to get authorization URL', e);
} finally {
hideIsLoading();
}
};
</script>
<HeadTitle title="OAuth" />
<main>
<Headline>OAuth</Headline>
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
<Table
columns={['Name', 'Status']}
sortable={['name', 'is_authorized']}
pagination={tableURLParams}
hasData={isProviderTableLoading || providers.length > 0}
hasNextPage={providersHasNextPage}
plural="OAuth providers"
isGhost={isProviderTableLoading}
hasActions
>
{#each providers as provider}
<TableRow>
<TableCell>
<button
on:click={() => {
openUpdateModal(provider.id);
}}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
title={provider.name}
class="block w-full py-1 text-left"
>
{provider.name}
</button>
</TableCell>
<TableCell>
{#if provider.isAuthorized}
<span class="text-green-600 dark:text-green-400">Authorized</span>
{:else}
<span class="text-yellow-600 dark:text-yellow-400">Not Authorized</span>
{/if}
</TableCell>
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableCopyButton
title="Copy"
on:click={() => openCopyModal(provider.id)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
/>
<TableUpdateButton
on:click={() => openUpdateModal(provider.id)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
/>
{#if !provider.isAuthorized}
<button
type="button"
on:click={() => onClickAuthorize(provider.id)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Authorize</p>
</button>
{:else}
<button
type="button"
on:click={() => onClickAuthorize(provider.id)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Re-authorize</p>
</button>
<button
type="button"
on:click={() => openRemoveAuthAlert(provider)}
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
>
<p class="ml-2 text-left">Remove Authorization</p>
</button>
{/if}
<TableDeleteButton
on:click={() => openDeleteAlert(provider)}
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
/>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onClickSubmit} bind:bindTo={form} {isSubmitting}>
<FormColumns>
<FormColumn>
<TextField
required
minLength={1}
maxLength={127}
bind:value={formValues.name}
placeholder="My OAuth Provider"
>
Name
</TextField>
<TextField
required
minLength={1}
maxLength={255}
bind:value={formValues.clientID}
placeholder="your-client-id"
>
Client ID
</TextField>
<PasswordField
required={modalMode === 'create' || modalMode === 'copy'}
minLength={modalMode === 'update' ? 0 : 1}
maxLength={255}
bind:value={formValues.clientSecret}
placeholder={modalMode === 'update'
? 'Leave empty to keep existing secret'
: 'your-client-secret'}
>
Client Secret
</PasswordField>
<TextField
required
minLength={1}
maxLength={512}
bind:value={formValues.authURL}
placeholder="https://example.com/oauth2/v2/auth"
>
Authorization URL
</TextField>
<TextField
required
minLength={1}
maxLength={512}
bind:value={formValues.tokenURL}
placeholder="https://example.com/oauth2/token"
>
Token URL
</TextField>
<TextareaField
required
minLength={1}
maxLength={512}
bind:value={formValues.scopes}
placeholder="https://example.com/auth/mail.send"
height="small"
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
>
</FormColumn>
</FormColumns>
<FormError message={formError} />
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
<DeleteAlert
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
/>
<DeleteAlert
name={removeAuthValues.name}
onClick={() => onClickRemoveAuth(removeAuthValues.id)}
bind:isVisible={isRemoveAuthAlertVisible}
title="Remove Authorization"
actionMessage="Are you sure you want to remove authorization from"
list={[
'Access and refresh tokens will be deleted',
'You will need to re-authorize to use this provider for sending'
]}
permanent={false}
/>
</main>