merge v0.23.0-rc changes

This commit is contained in:
Gani Georgiev
2024-09-29 19:23:19 +03:00
parent ad92992324
commit 844f18cac3
753 changed files with 85141 additions and 63396 deletions
@@ -1,46 +0,0 @@
<script>
import AuthProviderPanel from "@/components/settings/AuthProviderPanel.svelte";
export let provider = {};
export let config = {};
let providerPanel;
</script>
<div class="provider-card">
<figure class="provider-logo">
{#if provider.logo}
<img src="{import.meta.env.BASE_URL}images/oauth2/{provider.logo}" alt="{provider.title} logo" />
{/if}
</figure>
<div class="title">{provider.title}</div>
<em class="txt-hint txt-sm m-r-auto">({provider.key.slice(0, -4)})</em>
{#if config.enabled}
<div class="label label-success">Enabled</div>
{/if}
<button
type="button"
class="btn btn-circle btn-hint btn-transparent"
aria-label="Provider settings"
on:click={() => {
providerPanel?.show(
provider,
Object.assign({}, config, {
enabled: config.clientId ? config.enabled : true,
pkce: config.clientId ? config.pkce : null,
}),
);
}}
>
<i class="ri-settings-4-line" />
</button>
</div>
<AuthProviderPanel
bind:this={providerPanel}
on:submit={(e) => {
if (e.detail[provider.key]) {
config = e.detail[provider.key];
}
}}
/>
@@ -1,136 +0,0 @@
<script>
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
import { setErrors } from "@/stores/errors";
import { addSuccessToast } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
const formId = "provider_popup_" + CommonHelper.randomString(5);
let panel;
let provider = {};
let config = {};
let isSubmitting = false;
let initialHash = "";
$: hasChanges = JSON.stringify(config) != initialHash;
export function show(showProvider, showConfig) {
setErrors({}); // reset any previous errors
provider = Object.assign({}, showProvider);
config = Object.assign({ enabled: true }, showConfig);
initialHash = JSON.stringify(config);
panel?.show();
}
export function hide() {
return panel?.hide();
}
async function submit() {
isSubmitting = true;
try {
const data = {};
data[provider.key] = CommonHelper.filterRedactedProps(config);
const result = await ApiClient.settings.update(data);
setErrors({});
addSuccessToast("Successfully updated provider settings.");
dispatch("submit", result);
hide();
} catch (err) {
ApiClient.error(err);
}
isSubmitting = false;
}
function clear() {
for (let k in config) {
config[k] = CommonHelper.zeroValue(config[k]);
}
// set to false only for the oidc providers
// (@todo remove after the refactoring)
if (provider.key?.startsWith("oidc")) {
config.pkce = false;
} else {
config.pkce = null;
}
}
</script>
<OverlayPanel bind:this={panel} overlayClose={!isSubmitting} escClose={!isSubmitting} on:show on:hide>
<svelte:fragment slot="header">
<h4 class="center txt-break">{provider.title || provider.key} provider</h4>
</svelte:fragment>
<form id={formId} autocomplete="off" on:submit|preventDefault={() => submit()}>
<div class="flex m-b-base">
<Field class="form-field form-field-toggle m-b-0" name="{provider.key}.enabled" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={config.enabled} />
<label for={uniqueId}>Enable</label>
</Field>
<button type="button" class="btn btn-sm btn-transparent btn-hint m-l-auto" on:click={clear}>
<span class="txt">Clear all fields</span>
</button>
</div>
<Field
class="form-field {config.enabled ? 'required' : ''}"
name="{provider.key}.clientId"
let:uniqueId
>
<label for={uniqueId}>Client ID</label>
<input type="text" id={uniqueId} bind:value={config.clientId} required={config.enabled} />
</Field>
<Field
class="form-field {config.enabled ? 'required' : ''}"
name="{provider.key}.clientSecret"
let:uniqueId
>
<label for={uniqueId}>Client secret</label>
<RedactedPasswordInput bind:value={config.clientSecret} id={uniqueId} required={config.enabled} />
</Field>
{#if provider.optionsComponent}
<div class="col-lg-12">
<svelte:component
this={provider.optionsComponent}
key={provider.key}
bind:config
{...provider.optionsComponentProps || {}}
/>
</div>
{/if}
</form>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
Close
</button>
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSubmitting}
disabled={!hasChanges || isSubmitting}
>
<span class="txt">Save changes</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -88,7 +88,7 @@
the backup and will restart the application process.
</p>
<p>
This means that on success all of your data (including app settings, users, admins, etc.) will
This means that on success all of your data (including app settings, users, superusers, etc.) will
be replaced with the ones from the backup.
</p>
<p>
@@ -1,8 +1,9 @@
<script>
import { createEventDispatcher, onDestroy } from "svelte";
import ApiClient from "@/utils/ApiClient";
import { addSuccessToast, addErrorToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import { addSuccessToast, addErrorToast } from "@/stores/toasts";
import { confirm } from "@/stores/confirmation";
const dispatch = createEventDispatcher();
const backupRequestKey = "upload_backup";
@@ -13,20 +14,42 @@
let fileInput;
let isUploading = false;
async function upload(e) {
if (isUploading || !e?.target?.files?.length) {
function resetSelectedFile() {
if (fileInput) {
fileInput.value = "";
}
}
function uploadConfirm(file) {
if (!file) {
return;
}
confirm(
`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the source.\n\n` +
`Do you really want to upload "${file.name}"?`,
() => {
upload(file);
},
() => {
resetSelectedFile();
},
);
}
async function upload(file) {
if (isUploading || !file) {
return;
}
isUploading = true;
const data = new FormData();
data.set("file", e.target.files[0]);
data.set("file", file);
try {
await ApiClient.backups.upload(data, { requestKey: backupRequestKey });
isUploading = false;
dispatch("success");
addSuccessToast("Successfully uploaded a new backup.");
} catch (err) {
@@ -39,6 +62,8 @@
}
}
}
resetSelectedFile();
}
onDestroy(() => {
@@ -58,4 +83,12 @@
<i class="ri-upload-cloud-line" />
</button>
<input bind:this={fileInput} type="file" accept="application/zip" class="hidden" on:change={upload} />
<input
bind:this={fileInput}
type="file"
accept="application/zip"
class="hidden"
on:change={(e) => {
uploadConfirm(e?.target?.files?.[0]);
}}
/>
@@ -56,8 +56,8 @@
isDownloading[name] = true;
try {
const token = await ApiClient.getAdminFileToken();
const url = ApiClient.backups.getDownloadUrl(token, name);
const token = await ApiClient.getSuperuserFileToken();
const url = ApiClient.backups.getDownloadURL(token, name);
CommonHelper.download(url);
} catch (err) {
if (!err.isAbort) {
@@ -0,0 +1,91 @@
<script>
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import { errors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import { scale } from "svelte/transition";
export let formSettings;
$: hasErrors = !CommonHelper.isEmpty($errors?.batch);
$: isEnabled = !!formSettings.batch?.enabled;
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-archive-stack-line"></i>
<span class="txt">Batch API</span>
</div>
<div class="flex-fill" />
{#if isEnabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#if hasErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<Field class="form-field form-field-toggle m-b-sm" name="batch.enabled" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={formSettings.batch.enabled} />
<label for={uniqueId}>Enable</label>
</Field>
<div class="grid">
<div class="col-lg-4">
<Field class="form-field {isEnabled ? 'required' : ''}" name="batch.maxRequests" let:uniqueId>
<label for={uniqueId}>Max allowed batch requests</label>
<input
type="number"
id={uniqueId}
min="0"
step="1"
required={isEnabled}
bind:value={formSettings.batch.maxRequests}
/>
</Field>
</div>
<div class="col-lg-4">
<Field class="form-field {isEnabled ? 'required' : ''}" name="batch.timeout" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Max processing time (in seconds)</span>
</label>
<input
type="number"
id={uniqueId}
min="0"
step="1"
required={isEnabled}
bind:value={formSettings.batch.timeout}
/>
</Field>
</div>
<div class="col-lg-4">
<Field class="form-field" name="batch.maxBodySize" let:uniqueId>
<label for={uniqueId}>Max body size (in bytes)</label>
<input
type="number"
id={uniqueId}
min="0"
step="1"
placeholder="Default to 128MB"
value={formSettings.batch.maxBodySize || ""}
on:input={(e) => (formSettings.batch.maxBodySize = e.target.value << 0)}
/>
</Field>
</div>
</div>
</Accordion>
@@ -1,181 +0,0 @@
<script context="module">
let cachedEditorComponent;
</script>
<script>
import { scale } from "svelte/transition";
import tooltip from "@/actions/tooltip";
import { errors, removeError } from "@/stores/errors";
import { addInfoToast } from "@/stores/toasts";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import Accordion from "@/components/base/Accordion.svelte";
export let key;
export let title;
export let config = {};
let accordion;
let editorComponent = cachedEditorComponent;
let isEditorComponentLoading = false;
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, key));
$: if (!config.enabled) {
removeError(key);
}
export function expand() {
accordion?.expand();
}
export function collapse() {
accordion?.collapse();
}
export function collapseSiblings() {
accordion?.collapseSiblings();
}
async function loadEditorComponent() {
if (editorComponent || isEditorComponentLoading) {
return; // already loaded or in the process
}
isEditorComponentLoading = true;
editorComponent = (await import("@/components/base/CodeEditor.svelte")).default;
cachedEditorComponent = editorComponent;
isEditorComponentLoading = false;
}
function copy(param) {
CommonHelper.copyToClipboard(param);
addInfoToast(`Copied ${param} to clipboard`, 2000);
}
loadEditorComponent();
</script>
<Accordion bind:this={accordion} on:expand on:collapse on:toggle {...$$restProps}>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-draft-line" />
<span class="txt">{title}</span>
</div>
<div class="flex-fill" />
{#if hasErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<Field class="form-field required" name="{key}.subject" let:uniqueId>
<label for={uniqueId}>Subject</label>
<input type="text" id={uniqueId} bind:value={config.subject} spellcheck="false" required />
<div class="help-block">
Available placeholder parameters:
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_NAME}")}
>
{"{APP_NAME}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_URL}")}
>
{"{APP_URL}"}
</button>.
</div>
</Field>
<Field class="form-field required" name="{key}.actionUrl" let:uniqueId>
<label for={uniqueId}>Action URL</label>
<input type="text" id={uniqueId} bind:value={config.actionUrl} spellcheck="false" required />
<div class="help-block">
Available placeholder parameters:
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_NAME}")}
>
{"{APP_NAME}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_URL}")}
>
{"{APP_URL}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
title="Required parameter"
on:click={() => copy("{TOKEN}")}
>
{"{TOKEN}"}
</button>.
</div>
</Field>
<Field class="form-field m-0 required" name="{key}.body" let:uniqueId>
<label for={uniqueId}>Body (HTML)</label>
{#if editorComponent && !isEditorComponentLoading}
<svelte:component this={editorComponent} id={uniqueId} language="html" bind:value={config.body} />
{:else}
<textarea
id={uniqueId}
class="txt-mono"
spellcheck="false"
rows="14"
required
bind:value={config.body}
/>
{/if}
<div class="help-block">
Available placeholder parameters:
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_NAME}")}
>
{"{APP_NAME}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_URL}")}
>
{"{APP_URL}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{TOKEN}")}
>
{"{TOKEN}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
title="Required parameter"
on:click={() => copy("{ACTION_URL}")}
>
{"{ACTION_URL}"}
</button>.
</div>
</Field>
</Accordion>
@@ -1,11 +1,11 @@
<script>
import { createEventDispatcher, tick } from "svelte";
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import { setErrors } from "@/stores/errors";
import { addErrorToast, addSuccessToast } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { addErrorToast, addSuccessToast } from "@/stores/toasts";
import { setErrors } from "@/stores/errors";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Field from "@/components/base/Field.svelte";
import { createEventDispatcher, tick } from "svelte";
const dispatch = createEventDispatcher();
@@ -14,12 +14,15 @@
const testRequestKey = "email_test_request";
const templateOptions = [
{ label: '"Verification" template', value: "verification" },
{ label: '"Password reset" template', value: "password-reset" },
{ label: '"Confirm email change" template', value: "email-change" },
{ label: "Verification", value: "verification" },
{ label: "Password reset", value: "password-reset" },
{ label: "Confirm email change", value: "email-change" },
{ label: "OTP", value: "otp" },
{ label: "Login alert", value: "login-alert" },
];
let panel;
let collectionIdOrName = "";
let email = localStorage.getItem(emailStorageKey);
let template = templateOptions[0].value;
let isSubmitting = false;
@@ -27,7 +30,8 @@
$: canSubmit = !!email && !!template;
export function show(emailArg = "", templateArg = "") {
export function show(collectionArg = "", emailArg = "", templateArg = "") {
collectionIdOrName = collectionArg || "_superusers";
email = emailArg || localStorage.getItem(emailStorageKey);
template = templateArg || templateOptions[0].value;
@@ -59,7 +63,7 @@
}, 30000);
try {
await ApiClient.settings.testEmail(email, template, {
await ApiClient.settings.testEmail(collectionIdOrName, email, template, {
$cancelKey: testRequestKey,
});
@@ -73,9 +73,9 @@
deletedFieldNames.push(old.name + ".*");
} else {
// add only deleted fields
const schema = Array.isArray(old.schema) ? old.schema : [];
for (const field of schema) {
if (!CommonHelper.findByKey(imported.schema, "id", field.id)) {
const fields = Array.isArray(old.fields) ? old.fields : [];
for (const field of fields) {
if (!CommonHelper.findByKey(imported.fields, "id", field.id)) {
deletedFieldNames.push(`${old.name}.${field.name} (${field.id})`);
}
}
@@ -86,11 +86,11 @@
if (deletedFieldNames.length) {
confirm(
`Do you really want to delete the following collection fields and their related records data:\n- ${deletedFieldNames.join(
"\n- "
"\n- ",
)}`,
() => {
submit();
}
},
);
} else {
submit();
@@ -1,12 +1,15 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle, appName, hideControls } from "@/stores/app";
import { addSuccessToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import { addSuccessToast } from "@/stores/toasts";
import { appName, hideControls, pageTitle } from "@/stores/app";
import Field from "@/components/base/Field.svelte";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import BatchAccordion from "@/components/settings/BatchAccordion.svelte";
import TrustedProxyAccordion from "@/components/settings/TrustedProxyAccordion.svelte";
import RateLimitAccordion from "@/components/settings/RateLimitAccordion.svelte";
$pageTitle = "Application settings";
@@ -15,6 +18,7 @@
let isLoading = false;
let isSaving = false;
let initialHash = "";
let healthData = {};
$: initialHash = JSON.stringify(originalFormSettings);
@@ -22,12 +26,22 @@
loadSettings();
async function loadHealthData() {
try {
healthData = ((await ApiClient.health.check()) || {})?.data || {};
} catch (err) {
console.warn("Health check failed:", err);
}
}
async function loadSettings() {
isLoading = true;
try {
const settings = (await ApiClient.settings.getAll()) || {};
init(settings);
await loadHealthData();
} catch (err) {
ApiClient.error(err);
}
@@ -45,6 +59,9 @@
try {
const settings = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
init(settings);
await loadHealthData();
addSuccessToast("Successfully saved application settings.");
} catch (err) {
ApiClient.error(err);
@@ -59,6 +76,9 @@
formSettings = {
meta: settings?.meta || {},
batch: settings.batch || {},
trustedProxy: settings.trustedProxy || { headers: [] },
rateLimits: settings.rateLimits || { tags: [] },
};
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
@@ -98,49 +118,62 @@
</div>
<div class="col-lg-6">
<Field class="form-field required" name="meta.appUrl" let:uniqueId>
<Field class="form-field required" name="meta.appURL" let:uniqueId>
<label for={uniqueId}>Application URL</label>
<input type="text" id={uniqueId} required bind:value={formSettings.meta.appUrl} />
<input type="text" id={uniqueId} required bind:value={formSettings.meta.appURL} />
</Field>
</div>
<Field class="form-field form-field-toggle" name="meta.hideControls" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={formSettings.meta.hideControls} />
<label for={uniqueId}>
<span class="txt">Hide collection create and edit controls</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `This could prevent making accidental schema changes when in production environment.`,
position: "right",
}}
/>
</label>
</Field>
<div class="col-lg-12 flex">
<div class="flex-fill" />
{#if hasChanges}
<button
type="button"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
<span class="txt">Cancel</span>
</button>
{/if}
<button
type="submit"
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!hasChanges || isSaving}
on:click={() => save()}
>
<span class="txt">Save changes</span>
</button>
<div class="col-lg-12">
<div class="accordions">
<TrustedProxyAccordion bind:formSettings {healthData} />
<RateLimitAccordion bind:formSettings />
<BatchAccordion bind:formSettings />
</div>
</div>
<div class="col-lg-12">
<Field class="form-field form-field-toggle m-0" name="meta.hideControls" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={formSettings.meta.hideControls}
/>
<label for={uniqueId}>
<span class="txt">Hide collection create and edit controls</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `This could prevent making accidental schema changes when in production environment.`,
position: "right",
}}
/>
</label>
</Field>
</div>
</div>
<div class="flex m-t-base">
<div class="flex-fill" />
{#if hasChanges}
<button
type="button"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
<span class="txt">Cancel</span>
</button>
{/if}
<button
type="submit"
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!hasChanges || isSaving}
on:click={() => save()}
>
<span class="txt">Save changes</span>
</button>
</div>
{/if}
</form>
@@ -1,82 +0,0 @@
<script>
import ApiClient from "@/utils/ApiClient";
import { pageTitle } from "@/stores/app";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import AuthProviderCard from "@/components/settings/AuthProviderCard.svelte";
import providersList from "@/providers.js";
$pageTitle = "Auth providers";
let isLoading = false;
let formSettings = {};
$: enabledProviders = providersList.filter((provider) => formSettings[provider.key]?.enabled);
$: disabledProviders = providersList.filter((provider) => !formSettings[provider.key]?.enabled);
loadSettings();
async function loadSettings() {
isLoading = true;
try {
const result = (await ApiClient.settings.getAll()) || {};
initSettings(result);
} catch (err) {
ApiClient.error(err);
}
isLoading = false;
}
function initSettings(data) {
data = data || {};
formSettings = {};
for (const provider of providersList) {
formSettings[provider.key] = Object.assign({ enabled: false }, data[provider.key]);
}
}
</script>
<SettingsSidebar />
<PageWrapper>
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Settings</div>
<div class="breadcrumb-item">{$pageTitle}</div>
</nav>
</header>
<div class="wrapper">
<div class="panel">
<h6 class="m-b-base">Manage the allowed users OAuth2 sign-in/sign-up methods.</h6>
{#if isLoading}
<div class="loader" />
{:else}
<div class="grid grid-sm">
{#each enabledProviders as provider (provider.key)}
<div class="col-lg-6">
<AuthProviderCard {provider} bind:config={formSettings[provider.key]} />
</div>
{/each}
</div>
{#if enabledProviders.length > 0 && disabledProviders.length > 0}
<hr />
{/if}
<div class="grid grid-sm">
{#each disabledProviders as provider (provider.key)}
<div class="col-lg-6">
<AuthProviderCard {provider} bind:config={formSettings[provider.key]} />
</div>
{/each}
</div>
{/if}
</div>
</div>
</PageWrapper>
@@ -3,7 +3,7 @@
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { removeError } from "@/stores/errors";
import { removeError, setErrors } from "@/stores/errors";
import { addSuccessToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
@@ -62,6 +62,8 @@
try {
const settings = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
setErrors({});
await refreshList();
init(settings);
@@ -139,7 +141,7 @@
transition:slide={{ duration: 150 }}
>
<Field class="form-field form-field-toggle m-t-base m-b-0" let:uniqueId>
<input type="checkbox" id={uniqueId} required bind:checked={enableAutoBackups} />
<input type="checkbox" id={uniqueId} bind:checked={enableAutoBackups} />
<label for={uniqueId}>Enable auto backups</label>
</Field>
@@ -274,7 +276,7 @@
{#if hasChanges}
<button
type="submit"
type="button"
class="btn btn-hint btn-transparent"
disabled={!hasChanges || isSaving}
on:click={() => reset()}
@@ -35,10 +35,13 @@
collections = CommonHelper.sortCollections(collections);
// delete timestamps
for (let collection of collections) {
// delete timestamps
delete collection.created;
delete collection.updated;
// unset oauth2 providers
delete collection.oauth2?.providers;
}
selectAll();
@@ -167,7 +170,7 @@
<span class="txt">Copy</span>
</button>
<pre class="code-wrapper">{@html schema}</pre>
<pre class="code-wrapper">{schema}</pre>
</div>
</div>
@@ -1,14 +1,14 @@
<script>
import { tick } from "svelte";
import Field from "@/components/base/Field.svelte";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import ImportPopup from "@/components/settings/ImportPopup.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import { pageTitle } from "@/stores/app";
import { setErrors } from "@/stores/errors";
import { addErrorToast } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { addErrorToast } from "@/stores/toasts";
import { setErrors } from "@/stores/errors";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Field from "@/components/base/Field.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import ImportPopup from "@/components/settings/ImportPopup.svelte";
import { tick } from "svelte";
$pageTitle = "Import collections";
@@ -69,15 +69,15 @@
}
// check for matching schema fields
const oldSchema = Array.isArray(old.schema) ? old.schema : [];
const newSchema = Array.isArray(collection.schema) ? collection.schema : [];
for (const field of newSchema) {
const oldFieldById = CommonHelper.findByKey(oldSchema, "id", field.id);
const oldFields = Array.isArray(old.fields) ? old.fields : [];
const newFields = Array.isArray(collection.fields) ? collection.fields : [];
for (const field of newFields) {
const oldFieldById = CommonHelper.findByKey(oldFields, "id", field.id);
if (oldFieldById) {
continue; // no need to do any replacements
}
const oldFieldByName = CommonHelper.findByKey(oldSchema, "name", field.name);
const oldFieldByName = CommonHelper.findByKey(oldFields, "name", field.name);
if (oldFieldByName && field.id != oldFieldByName.id) {
return true;
}
@@ -93,10 +93,13 @@
try {
oldCollections = await ApiClient.collections.getFullList(200);
// delete timestamps
for (let collection of oldCollections) {
// delete timestamps
delete collection.created;
delete collection.updated;
// unset oauth2 providers
delete collection.oauth2?.providers;
}
} catch (err) {
ApiClient.error(err);
@@ -150,7 +153,7 @@
delete collection.updated;
// merge fields with duplicated ids
collection.schema = CommonHelper.filterDuplicatesByKey(collection.schema);
collection.fields = CommonHelper.filterDuplicatesByKey(collection.fields);
}
}
@@ -169,10 +172,10 @@
collection.id = replacedId;
// replace field ids
const oldSchema = Array.isArray(old.schema) ? old.schema : [];
const newSchema = Array.isArray(collection.schema) ? collection.schema : [];
for (const field of newSchema) {
const oldField = CommonHelper.findByKey(oldSchema, "name", field.name);
const oldFields = Array.isArray(old.fields) ? old.fields : [];
const newFields = Array.isArray(collection.fields) ? collection.fields : [];
for (const field of newFields) {
const oldField = CommonHelper.findByKey(oldFields, "name", field.name);
if (oldField && oldField.id) {
field.id = oldField.id;
}
@@ -180,12 +183,12 @@
// update references
for (let ref of newCollections) {
if (!Array.isArray(ref.schema)) {
if (!Array.isArray(ref.fields)) {
continue;
}
for (let field of ref.schema) {
if (field.options?.collectionId && field.options?.collectionId === originalId) {
field.options.collectionId = replacedId;
for (let field of ref.fields) {
if (field.collectionId && field.collectionId === originalId) {
field.collectionId = replacedId;
}
}
}
+14 -42
View File
@@ -1,18 +1,17 @@
<script>
import { slide } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
import EmailTestPopup from "@/components/settings/EmailTestPopup.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import { pageTitle } from "@/stores/app";
import { setErrors } from "@/stores/errors";
import { addSuccessToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import EmailTemplateAccordion from "@/components/settings/EmailTemplateAccordion.svelte";
import EmailTestPopup from "@/components/settings/EmailTestPopup.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { slide } from "svelte/transition";
const tlsOptions = [
{ label: "Auto (StartTLS)", value: false },
@@ -31,6 +30,7 @@
let formSettings = {};
let isLoading = false;
let isSaving = false;
let maskPassword = false;
let showMoreOptions = false;
$: initialHash = JSON.stringify(originalFormSettings);
@@ -82,6 +82,8 @@
}
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
maskPassword = !!formSettings.smtp.username;
}
function reset() {
@@ -134,37 +136,6 @@
</div>
</div>
<div class="accordions">
{#if !formSettings.meta.verificationTemplate.hidden}
<EmailTemplateAccordion
single
key="meta.verificationTemplate"
title={'Default "Verification" email template'}
bind:config={formSettings.meta.verificationTemplate}
/>
{/if}
{#if !formSettings.meta.resetPasswordTemplate.hidden}
<EmailTemplateAccordion
single
key="meta.resetPasswordTemplate"
title={'Default "Password reset" email template'}
bind:config={formSettings.meta.resetPasswordTemplate}
/>
{/if}
{#if !formSettings.meta.confirmEmailChangeTemplate.hidden}
<EmailTemplateAccordion
single
key="meta.confirmEmailChangeTemplate"
title={'Default "Confirm email change" email template'}
bind:config={formSettings.meta.confirmEmailChangeTemplate}
/>
{/if}
</div>
<hr />
<Field class="form-field form-field-toggle m-b-sm" let:uniqueId>
<input type="checkbox" id={uniqueId} required bind:checked={formSettings.smtp.enabled} />
<label for={uniqueId}>
@@ -219,6 +190,7 @@
<label for={uniqueId}>Password</label>
<RedactedPasswordInput
id={uniqueId}
bind:mask={maskPassword}
bind:value={formSettings.smtp.password}
/>
</Field>
@@ -1,154 +0,0 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { addSuccessToast } from "@/stores/toasts";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import TokenField from "@/components/settings/TokenField.svelte";
const recordTokensList = [
{ key: "recordAuthToken", label: "Auth record authentication token" },
{ key: "recordVerificationToken", label: "Auth record email verification token" },
{ key: "recordPasswordResetToken", label: "Auth record password reset token" },
{ key: "recordEmailChangeToken", label: "Auth record email change token" },
{ key: "recordFileToken", label: "Records protected file access token" },
];
const adminTokensList = [
{ key: "adminAuthToken", label: "Admins auth token" },
{ key: "adminPasswordResetToken", label: "Admins password reset token" },
{ key: "adminFileToken", label: "Admins protected file access token" },
];
$pageTitle = "Token options";
let originalFormSettings = {};
let formSettings = {};
let isLoading = false;
let isSaving = false;
$: initialHash = JSON.stringify(originalFormSettings);
$: hasChanges = initialHash != JSON.stringify(formSettings);
loadSettings();
async function loadSettings() {
isLoading = true;
try {
const result = (await ApiClient.settings.getAll()) || {};
initSettings(result);
} catch (err) {
ApiClient.error(err);
}
isLoading = false;
}
async function save() {
if (isSaving || !hasChanges) {
return;
}
isSaving = true;
try {
const result = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
initSettings(result);
addSuccessToast("Successfully saved tokens options.");
} catch (err) {
ApiClient.error(err);
}
isSaving = false;
}
function initSettings(data) {
data = data || {};
formSettings = {};
const tokensList = recordTokensList.concat(adminTokensList);
for (const listItem of tokensList) {
formSettings[listItem.key] = {
duration: data[listItem.key]?.duration || 0,
};
}
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
}
function reset() {
formSettings = JSON.parse(JSON.stringify(originalFormSettings || {}));
}
</script>
<SettingsSidebar />
<PageWrapper>
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Settings</div>
<div class="breadcrumb-item">{$pageTitle}</div>
</nav>
</header>
<div class="wrapper">
<form class="panel" autocomplete="off" on:submit|preventDefault={save}>
<div class="content m-b-sm txt-xl">
<p>Adjust common token options.</p>
</div>
{#if isLoading}
<div class="loader" />
{:else}
<h3 class="section-title">Record tokens</h3>
{#each recordTokensList as token (token.key)}
<TokenField
key={token.key}
label={token.label}
bind:duration={formSettings[token.key].duration}
bind:secret={formSettings[token.key].secret}
/>
{/each}
<hr />
<h3 class="section-title">Admin tokens</h3>
{#each adminTokensList as token (token.key)}
<TokenField
key={token.key}
label={token.label}
bind:duration={formSettings[token.key].duration}
bind:secret={formSettings[token.key].secret}
/>
{/each}
<div class="flex">
<div class="flex-fill" />
{#if hasChanges}
<button
type="button"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
<span class="txt">Cancel</span>
</button>
{/if}
<button
type="submit"
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!hasChanges || isSaving}
on:click={() => save()}
>
<span class="txt">Save changes</span>
</button>
</div>
{/if}
</form>
</div>
</PageWrapper>
@@ -0,0 +1,237 @@
<script>
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import AutocompleteInput from "@/components/base/AutocompleteInput.svelte";
import { errors, setErrors } from "@/stores/errors";
import { collections, loadCollections } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
import { scale } from "svelte/transition";
export let formSettings;
const basePredefinedTags = [
{ value: "*:list" },
{ value: "*:view" },
{ value: "*:create" },
{ value: "*:update" },
{ value: "*:delete" },
{ value: "*:file" },
{ value: "*:listAuthMethods" },
{ value: "*:authRefresh" },
{ value: "*:auth" },
{ value: "*:authWithPassword" },
{ value: "*:authWithOAuth2" },
{ value: "*:authWithOTP" },
{ value: "*:requestOTP" },
{ value: "*:requestPasswordReset" },
{ value: "*:confirmPasswordReset" },
{ value: "*:requestVerification" },
{ value: "*:confirmVerification" },
{ value: "*:requestEmailChange" },
{ value: "*:confirmEmailChange" },
];
let predefinedTags = basePredefinedTags;
$: hasErrors = !CommonHelper.isEmpty($errors?.rateLimits);
loadPredefinedTags();
async function loadPredefinedTags() {
await loadCollections();
predefinedTags = [];
for (let collection of $collections) {
if (collection.system) {
continue;
}
predefinedTags.push({ value: collection.name + ":list" });
predefinedTags.push({ value: collection.name + ":view" });
if (collection.type != "view") {
predefinedTags.push({ value: collection.name + ":create" });
predefinedTags.push({ value: collection.name + ":update" });
predefinedTags.push({ value: collection.name + ":delete" });
}
if (collection.type == "auth") {
predefinedTags.push({ value: collection.name + ":listAuthMethods" });
predefinedTags.push({ value: collection.name + ":authRefresh" });
predefinedTags.push({ value: collection.name + ":auth" });
predefinedTags.push({ value: collection.name + ":authWithPassword" });
predefinedTags.push({ value: collection.name + ":authWithOAuth2" });
predefinedTags.push({ value: collection.name + ":authWithOTP" });
predefinedTags.push({ value: collection.name + ":requestOTP" });
predefinedTags.push({ value: collection.name + ":requestPasswordReset" });
predefinedTags.push({ value: collection.name + ":confirmPasswordReset" });
predefinedTags.push({ value: collection.name + ":requestVerification" });
predefinedTags.push({ value: collection.name + ":confirmVerification" });
predefinedTags.push({ value: collection.name + ":requestEmailChange" });
predefinedTags.push({ value: collection.name + ":confirmEmailChange" });
}
if (collection.fields.find((f) => f.type == "file")) {
predefinedTags.push({ value: collection.name + ":file" });
}
}
predefinedTags = predefinedTags.concat(basePredefinedTags);
}
function newRule() {
setErrors({}); // reset
if (!Array.isArray(formSettings.rateLimits.rules)) {
formSettings.rateLimits.rules = [];
}
formSettings.rateLimits.rules.push({
label: "",
maxRequests: 300,
duration: 10,
});
formSettings.rateLimits.rules = formSettings.rateLimits.rules;
if (formSettings.rateLimits.rules.length == 1) {
formSettings.rateLimits.enabled = true;
}
}
function removeRule(i) {
setErrors({}); // reset
formSettings.rateLimits.rules.splice(i, 1);
formSettings.rateLimits.rules = formSettings.rateLimits.rules;
if (!formSettings.rateLimits.rules.length) {
formSettings.rateLimits.enabled = false;
}
}
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-pulse-fill"></i>
<span class="txt">Rate limiting</span>
</div>
<div class="flex-fill" />
{#if hasErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
{#if formSettings.rateLimits.enabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
</svelte:fragment>
<Field class="form-field form-field-toggle m-b-xs" name="rateLimits.enabled" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={formSettings.rateLimits.enabled} />
<label for={uniqueId}>Enable</label>
</Field>
{#if !CommonHelper.isEmpty(formSettings.rateLimits.rules)}
<table class="rate-limit-table">
<thead>
<tr>
<th>Rate limit label</th>
<th>Max requests (per IP)</th>
<th>Interval (in seconds)</th>
<th></th>
</tr>
</thead>
<tbody>
{#each formSettings.rateLimits.rules || [] as rule, i}
<tr class="rate-limit-row">
<td class="col-tag">
<Field class="form-field" name={"rateLimits.rules." + i + ".label"} inlineError>
<AutocompleteInput
required
placeholder="tag (users:create) or path (/api/)"
options={predefinedTags}
bind:value={rule.label}
/>
</Field>
</td>
<td class="col-requests">
<Field
class="form-field"
name={"rateLimits.rules." + i + ".maxRequests"}
inlineError
>
<input
type="number"
required
placeholder="Max requests*"
min="1"
step="1"
bind:value={rule.maxRequests}
/>
</Field>
</td>
<td class="col-burst">
<Field
class="form-field"
name={"rateLimits.rules." + i + ".duration"}
inlineError
>
<input
type="number"
required
placeholder="Interval*"
min="1"
step="1"
bind:value={rule.duration}
/>
</Field>
</td>
<td class="col-action">
<button
type="button"
title="Remove rule"
aria-label="Remove rule"
class="btn btn-xs btn-circle btn-hint btn-transparent"
on:click={() => removeRule(i)}
>
<i class="ri-close-line"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div class="flex m-t-sm">
<button
type="button"
class="btn btn-sm btn-secondary m-r-auto"
class:btn-danger={$errors?.rateLimits?.rules?.message}
on:click={() => newRule()}
>
<i class="ri-add-line"></i>
<span class="txt">Add rate limit rule</span>
</button>
<a
href={import.meta.env.PB_RATE_LIMIT_DOCS}
class="txt-nowrap txt-sm link-hint"
target="_blank"
rel="noopener noreferrer"
>
<em>Learn more about the rate limit rules</em>
</a>
</div>
</Accordion>
+12 -1
View File
@@ -19,8 +19,10 @@
let testTimeoutId = null;
let testDebounceId = null;
let maskSecret = false;
$: if (originalConfig?.enabled) {
refreshMaskSecret();
testConnectionWithDebounce(100);
}
@@ -29,6 +31,10 @@
removeError(configKey);
}
function refreshMaskSecret() {
maskSecret = !!originalConfig?.accessKey;
}
function testConnectionWithDebounce(timeout) {
isTesting = true;
clearTimeout(testDebounceId);
@@ -119,7 +125,12 @@
<div class="col-lg-6">
<Field class="form-field required" name="{configKey}.secret" let:uniqueId>
<label for={uniqueId}>Secret</label>
<RedactedPasswordInput id={uniqueId} required bind:value={config.secret} />
<RedactedPasswordInput
required
id={uniqueId}
bind:mask={maskSecret}
bind:value={config.secret}
/>
</Field>
</div>
<div class="col-lg-12">
@@ -1,8 +1,8 @@
<script>
import { link } from "svelte-spa-router";
import active from "svelte-spa-router/active";
import PageSidebar from "@/components/base/PageSidebar.svelte";
import { hideControls } from "@/stores/app";
import { link } from "svelte-spa-router";
import active from "svelte-spa-router/active";
</script>
<PageSidebar class="settings-sidebar">
@@ -63,34 +63,5 @@
<span class="txt">Import collections</span>
</a>
{/if}
<div class="sidebar-title">Authentication</div>
<a
href="/settings/auth-providers"
class="sidebar-list-item"
use:active={{ path: "/settings/auth-providers/?.*" }}
use:link
>
<i class="ri-lock-password-line" aria-hidden="true" />
<span class="txt">Auth providers</span>
</a>
<a
href="/settings/tokens"
class="sidebar-list-item"
use:active={{ path: "/settings/tokens/?.*" }}
use:link
>
<i class="ri-key-line" aria-hidden="true" />
<span class="txt">Token options</span>
</a>
<a
href="/settings/admins"
class="sidebar-list-item"
use:active={{ path: "/settings/admins/?.*" }}
use:link
>
<i class="ri-shield-user-line" aria-hidden="true" />
<span class="txt">Admins</span>
</a>
</div>
</PageSidebar>
@@ -1,31 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
export let key;
export let label;
export let duration;
export let secret;
</script>
<Field class="form-field required" name="{key}.duration" let:uniqueId>
<label for={uniqueId}>{label} duration (in seconds)</label>
<input type="number" id={uniqueId} required bind:value={duration} />
<div class="help-block">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="link-primary"
class:txt-success={!!secret}
on:click={() => {
// toggle
if (secret) {
secret = undefined;
} else {
secret = CommonHelper.randomSecret(50);
}
}}
>
Invalidate all previously issued tokens
</span>
</div>
</Field>
@@ -0,0 +1,172 @@
<script>
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import { errors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import { scale } from "svelte/transition";
const commonProxyHeaders = ["X-Forward-For", "Fly-Client-IP", "CF-Connecting-IP"];
export let formSettings;
export let healthData;
let initialSettingsHash = "";
$: settingsHash = JSON.stringify(formSettings);
$: if (initialSettingsHash != settingsHash) {
initialSettingsHash = settingsHash;
}
$: hasChanges = initialSettingsHash != settingsHash;
$: hasErrors = !CommonHelper.isEmpty($errors?.trustedProxy);
$: isEnabled = !CommonHelper.isEmpty(formSettings.trustedProxy.headers);
$: suggestedProxyHeaders = !healthData.possibleProxyHeader
? commonProxyHeaders
: [healthData.possibleProxyHeader].concat(
commonProxyHeaders.filter((h) => h != healthData.possibleProxyHeader),
);
function setHeader(val) {
formSettings.trustedProxy.headers = [val];
}
const ipOptions = [
{ label: "Use leftmost IP", value: true },
{ label: "Use rightmost IP", value: false },
];
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-route-line"></i>
<span class="txt">User IP proxy headers</span>
{#if !isEnabled && healthData.possibleProxyHeader}
<i
class="ri-alert-line txt-sm txt-warning"
use:tooltip={"Detected proxy header.\nIt is recommend to list it as trusted."}
/>
{:else if isEnabled && !hasChanges && !formSettings.trustedProxy.headers.includes(healthData.possibleProxyHeader)}
<i
class="ri-alert-line txt-sm txt-hint"
use:tooltip={"The configured proxy header doesn't match with the detected one."}
/>
{/if}
</div>
<div class="flex-fill" />
{#if isEnabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#if hasErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<div class="alert alert-info m-b-sm">
<div class="content">
<div class="inline-flex flex-gap-5">
<span>Resolved user IP:</span>
<strong>{healthData.realIP || "N/A"}</strong>
<i
class="ri-information-line txt-sm link-hint"
use:tooltip={"Must show your actual IP.\nIf not, set the correct proxy header."}
/>
</div>
<br />
<div class="inline-flex flex-gap-5">
<span>Detected proxy header:</span>
<strong>{healthData.possibleProxyHeader || "N/A"}</strong>
</div>
</div>
</div>
<div class="content m-b-sm">
<p>
When PocketBase is deployed on platforms like Fly or it is accessible through proxies such as
NGINX, requests from different users will originate from the same IP address (the IP of the proxy
connecting to your PocketBase app).
</p>
<p>
In this case to retrieve the actual user IP (used for rate limiting, logging, etc.) you need to
properly configure your proxy and list below the trusted headers that PocketBase could use to
extract the user IP.
</p>
<p class="txt-bold">When using such proxy, to avoid spoofing it is recommended to:</p>
<ul class="m-t-0 txt-bold">
<li>use headers that are controlled only by the proxy and cannot be manually set by the users</li>
<li>make sure that the PocketBase server can be accessed only through the proxy</li>
</ul>
<p>You can clear the headers field if PocketBase is not deployed behind a proxy.</p>
</div>
<div class="grid grid-sm">
<div class="col-lg-9">
<Field class="form-field m-b-0" name="trustedProxy.headers" let:uniqueId>
<label for={uniqueId}>Trusted proxy headers</label>
<MultipleValueInput
id={uniqueId}
placeholder="Leave empty to disable"
bind:value={formSettings.trustedProxy.headers}
/>
<div class="form-field-addon">
<button
type="button"
class="btn btn-sm btn-hint btn-transparent btn-clear"
class:hidden={CommonHelper.isEmpty(formSettings.trustedProxy.headers)}
on:click={() => (formSettings.trustedProxy.headers = [])}
>
Clear
</button>
</div>
<div class="help-block">
<p>
Comma separated list of headers such as:
{#each suggestedProxyHeaders as header}
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => setHeader(header)}
>
{header}
</button>&nbsp;
{/each}
</p>
</div>
</Field>
</div>
<div class="col-lg-3">
<Field class="form-field m-0" name="trustedProxy.useLeftmostIP" let:uniqueId>
<label for={uniqueId}>
<span class="txt">IP priority selection</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "This is in case the proxy returns more than 1 IP as header value. The rightmost IP is usually considered to be the more trustworthy but this could vary depending on the proxy.",
position: "right",
}}
/>
</label>
<ObjectSelect
items={ipOptions}
bind:keyOfSelected={formSettings.trustedProxy.useLeftmostIP}
/>
</Field>
</div>
</div>
</Accordion>
@@ -1,24 +0,0 @@
<script>
import AppleSecretPopup from "@/components/settings/providers/AppleSecretPopup.svelte";
export let key = "";
export let config = {};
let generatorPopup;
</script>
<button
type="button"
class="btn btn-sm btn-secondary btn-provider-{key}"
on:click={() => generatorPopup?.show({ clientId: config.clientId })}
>
<i class="ri-key-line" />
<span class="txt">Generate secret</span>
</button>
<AppleSecretPopup
bind:this={generatorPopup}
on:submit={(e) => {
config.clientSecret = e.detail?.secret || "";
}}
/>
@@ -1,152 +0,0 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { addSuccessToast } from "@/stores/toasts";
import { setErrors } from "@/stores/errors";
import tooltip from "@/actions/tooltip";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
const formId = "apple_secret_" + CommonHelper.randomString(5);
const maxDuration = 15777000; // 6 months
let panel;
let clientId;
let teamId;
let keyId;
let privateKey;
let duration;
let isSubmitting = false;
$: canSubmit = true;
export function show(config = {}) {
clientId = config.clientId || "";
teamId = config.teamId || "";
keyId = config.keyId || "";
privateKey = config.privateKey || "";
duration = config.duration || maxDuration;
setErrors({}); // reset any previous errors
panel?.show();
}
export function hide() {
return panel?.hide();
}
async function submit() {
isSubmitting = true;
try {
const result = await ApiClient.settings.generateAppleClientSecret(
clientId,
teamId,
keyId,
privateKey.trim(),
duration
);
isSubmitting = false;
addSuccessToast("Successfully generated client secret.");
dispatch("submit", result);
panel?.hide();
} catch (err) {
ApiClient.error(err);
}
isSubmitting = false;
}
</script>
<OverlayPanel
bind:this={panel}
overlayClose={!isSubmitting}
escClose={!isSubmitting}
beforeHide={() => !isSubmitting}
popup
on:show
on:hide
>
<svelte:fragment slot="header">
<h4 class="center txt-break">Generate Apple client secret</h4>
</svelte:fragment>
<form id={formId} autocomplete="off" on:submit|preventDefault={() => submit()}>
<div class="grid">
<div class="col-lg-6">
<Field class="form-field required" name="clientId" let:uniqueId>
<label for={uniqueId}>Client ID</label>
<input type="text" id={uniqueId} bind:value={clientId} required />
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="teamId" let:uniqueId>
<label for={uniqueId}>Team ID</label>
<input type="text" id={uniqueId} bind:value={teamId} required />
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="keyId" let:uniqueId>
<label for={uniqueId}>Key ID</label>
<input type="text" id={uniqueId} bind:value={keyId} required />
</Field>
</div>
<div class="col-lg-6">
<Field class="form-field required" name="duration" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Duration (in seconds)</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Max ${maxDuration} seconds (~${
(maxDuration / (60 * 60 * 24 * 30)) << 0
} months).`,
position: "top",
}}
/>
</label>
<input type="text" id={uniqueId} max={maxDuration} bind:value={duration} required />
</Field>
</div>
<Field class="form-field required" name="privateKey" let:uniqueId>
<label for={uniqueId}>Private key</label>
<textarea
id={uniqueId}
required
rows="8"
placeholder={"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"}
bind:value={privateKey}
/>
<div class="help-block">
The key is not stored on the server and it is used only for generating the signed JWT.
</div>
</Field>
</div>
</form>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}
>Close</button
>
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSubmitting}
disabled={!canSubmit || isSubmitting}
>
<i class="ri-key-line" />
<span class="txt">Generate and set secret</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -1,22 +0,0 @@
<script>
import Field from "@/components/base/Field.svelte";
export let key = "";
export let config = {};
</script>
<div class="section-title">Azure AD endpoints</div>
<Field class="form-field {config.enabled ? 'required' : ''}" name="{key}.authUrl" let:uniqueId>
<label for={uniqueId}>Auth URL</label>
<input type="url" id={uniqueId} bind:value={config.authUrl} required={config.enabled} />
<div class="help-block">
Eg. {`https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/authorize`}
</div>
</Field>
<Field class="form-field {config.enabled ? 'required' : ''}" name="{key}.tokenUrl" let:uniqueId>
<label for={uniqueId}>Token URL</label>
<input type="url" id={uniqueId} bind:value={config.tokenUrl} required={config.enabled} />
<div class="help-block">
Eg. {`https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/token`}
</div>
</Field>
@@ -1,54 +0,0 @@
<script>
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let key = "";
export let config = {};
$: isRequired = !!config.enabled;
if (CommonHelper.isEmpty(config.pkce)) {
config.pkce = true;
}
if (!config.displayName) {
config.displayName = "OIDC";
}
</script>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.displayName" let:uniqueId>
<label for={uniqueId}>Display name</label>
<input type="text" id={uniqueId} bind:value={config.displayName} required={isRequired} />
</Field>
<div class="section-title">Endpoints</div>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.authUrl" let:uniqueId>
<label for={uniqueId}>Auth URL</label>
<input type="url" id={uniqueId} bind:value={config.authUrl} required={isRequired} />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.tokenUrl" let:uniqueId>
<label for={uniqueId}>Token URL</label>
<input type="url" id={uniqueId} bind:value={config.tokenUrl} required={isRequired} />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.userApiUrl" let:uniqueId>
<label for={uniqueId}>User API URL</label>
<input type="url" id={uniqueId} bind:value={config.userApiUrl} required={isRequired} />
</Field>
<Field class="form-field" name="{key}.pkce" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={config.pkce} />
<label for={uniqueId}>
<span class="txt">Support PKCE</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Usually it should be safe to be always enabled as most providers will just ignore the extra query parameters if they don't support PKCE.",
position: "right",
}}
/>
</label>
</Field>
@@ -1,24 +0,0 @@
<script>
import Field from "@/components/base/Field.svelte";
export let key = "";
export let config = {};
export let required = false;
export let title = "Provider endpoints";
$: isRequired = required && config?.enabled;
</script>
<div class="section-title">{title}</div>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.authUrl" let:uniqueId>
<label for={uniqueId}>Auth URL</label>
<input type="url" id={uniqueId} bind:value={config.authUrl} required={isRequired} />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.tokenUrl" let:uniqueId>
<label for={uniqueId}>Token URL</label>
<input type="url" id={uniqueId} bind:value={config.tokenUrl} required={isRequired} />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.userApiUrl" let:uniqueId>
<label for={uniqueId}>User API URL</label>
<input type="url" id={uniqueId} bind:value={config.userApiUrl} required={isRequired} />
</Field>