288
common/resources/client/admin/settings/pages/authentication-settings.tsx
Executable file
288
common/resources/client/admin/settings/pages/authentication-settings.tsx
Executable file
@@ -0,0 +1,288 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {SettingsPanel} from '@common/admin/settings/settings-panel';
|
||||
import {FormSwitch} from '@common/ui/forms/toggle/switch';
|
||||
import {AdminSettings} from '@common/admin/settings/admin-settings';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Fragment} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {SettingsSeparator} from '@common/admin/settings/settings-separator';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
export function AuthenticationSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Authentication" />}
|
||||
description={
|
||||
<Trans message="Configure registration, social login and related 3rd party integrations." />
|
||||
}
|
||||
>
|
||||
<EmailConfirmationSection />
|
||||
<FormSwitch
|
||||
className="mb-24"
|
||||
name="client.registration.disable"
|
||||
description={
|
||||
<Trans message="All registration related functionality (including social login) will be disabled." />
|
||||
}
|
||||
>
|
||||
<Trans message="Disable registration" />
|
||||
</FormSwitch>
|
||||
<FormSwitch
|
||||
className="mb-24"
|
||||
name="client.single_device_login"
|
||||
description={
|
||||
<Trans message="Only allow one device to be logged into user account at the same time." />
|
||||
}
|
||||
>
|
||||
<Trans message="Single device login" />
|
||||
</FormSwitch>
|
||||
<FormSwitch
|
||||
name="client.social.compact_buttons"
|
||||
description={
|
||||
<Trans message="Use compact design for social login buttons." />
|
||||
}
|
||||
>
|
||||
<Trans message="Compact buttons" />
|
||||
</FormSwitch>
|
||||
<EnvatoSection />
|
||||
<GoogleSection />
|
||||
<FacebookSection />
|
||||
<TwitterSection />
|
||||
<SettingsSeparator />
|
||||
<FormTextField
|
||||
inputElementType="textarea"
|
||||
rows={3}
|
||||
className="mt-24"
|
||||
name="client.auth.domain_blacklist"
|
||||
label={<Trans message="Domain blacklist" />}
|
||||
description={
|
||||
<Trans message="Comma separated list of domains. Users will not be able to register or login using any email adress from specified domains." />
|
||||
}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailNotSetupWarning() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const mailSetup = watch('server.mail_setup');
|
||||
if (mailSetup) return null;
|
||||
|
||||
return (
|
||||
<p className="mt-10 rounded-panel border p-10 text-sm text-danger">
|
||||
<Trans
|
||||
message="Outgoing mail method needs to be setup before enabling this setting. <a>Fix now</a>"
|
||||
values={{
|
||||
a: text => (
|
||||
<Button
|
||||
elementType={Link}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
display="flex"
|
||||
className="mt-10 max-w-max"
|
||||
to="/admin/settings/outgoing-email"
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailConfirmationSection() {
|
||||
return (
|
||||
<FormSwitch
|
||||
className="mb-30"
|
||||
name="client.require_email_confirmation"
|
||||
description={
|
||||
<Fragment>
|
||||
<Trans message="Require newly registered users to validate their email address before being able to login." />
|
||||
<MailNotSetupWarning />
|
||||
</Fragment>
|
||||
}
|
||||
>
|
||||
<Trans message="Require email confirmation" />
|
||||
</FormSwitch>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvatoSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const settings = useSettings();
|
||||
const envatoLoginEnabled = watch('client.social.envato.enable');
|
||||
|
||||
if (!(settings as any).envato?.enable) return null;
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup separatorBottom={false} name="envato_group">
|
||||
{isInvalid => (
|
||||
<>
|
||||
<FormSwitch
|
||||
invalid={isInvalid}
|
||||
name="client.social.envato.enable"
|
||||
description={
|
||||
<Trans message="Enable logging into the site via envato." />
|
||||
}
|
||||
>
|
||||
<Trans message="Envato login" />
|
||||
</FormSwitch>
|
||||
{!!envatoLoginEnabled && (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.envato_id"
|
||||
label={<Trans message="Envato ID" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.envato_secret"
|
||||
label={<Trans message="Envato secret" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.envato_personal_token"
|
||||
label={<Trans message="Envato personal token" />}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const googleLoginEnabled = watch('client.social.google.enable');
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup name="google_group">
|
||||
{isInvalid => (
|
||||
<>
|
||||
<FormSwitch
|
||||
invalid={isInvalid}
|
||||
name="client.social.google.enable"
|
||||
description={
|
||||
<Trans message="Enable logging into the site via google." />
|
||||
}
|
||||
>
|
||||
<Trans message="Google login" />
|
||||
</FormSwitch>
|
||||
{!!googleLoginEnabled && (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.google_id"
|
||||
label={<Trans message="Google client ID" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mt-30"
|
||||
name="server.google_secret"
|
||||
label={<Trans message="Google client secret" />}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function FacebookSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const facebookLoginEnabled = watch('client.social.facebook.enable');
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup name="facebook_group" separatorTop={false}>
|
||||
{isInvalid => (
|
||||
<>
|
||||
<FormSwitch
|
||||
invalid={isInvalid}
|
||||
name="client.social.facebook.enable"
|
||||
description={
|
||||
<Trans message="Enable logging into the site via facebook." />
|
||||
}
|
||||
>
|
||||
<Trans message="Facebook login" />
|
||||
</FormSwitch>
|
||||
{!!facebookLoginEnabled && (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.facebook_id"
|
||||
label={<Trans message="Facebook app ID" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.facebook_secret"
|
||||
label={<Trans message="Facebook app secret" />}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitterSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const twitterLoginEnabled = watch('client.social.twitter.enable');
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup
|
||||
name="twitter_group"
|
||||
separatorTop={false}
|
||||
separatorBottom={false}
|
||||
>
|
||||
{isInvalid => (
|
||||
<>
|
||||
<FormSwitch
|
||||
invalid={isInvalid}
|
||||
name="client.social.twitter.enable"
|
||||
description={
|
||||
<Trans message="Enable logging into the site via twitter." />
|
||||
}
|
||||
>
|
||||
<Trans message="Twitter login" />
|
||||
</FormSwitch>
|
||||
{!!twitterLoginEnabled && (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.twitter_id"
|
||||
label={<Trans message="Twitter ID" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mt-30"
|
||||
name="server.twitter_secret"
|
||||
label={<Trans message="Twitter secret" />}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
120
common/resources/client/admin/settings/pages/cache-settings/cache-settings.tsx
Executable file
120
common/resources/client/admin/settings/pages/cache-settings/cache-settings.tsx
Executable file
@@ -0,0 +1,120 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {ComponentType} from 'react';
|
||||
import {SettingsPanel} from '../../settings-panel';
|
||||
import {FormSelect, Option} from '../../../../ui/forms/select/select';
|
||||
import {SettingsErrorGroup} from '../../settings-error-group';
|
||||
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {AdminSettings} from '../../admin-settings';
|
||||
import {useClearCache} from './clear-cache';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {SectionHelper} from '../../../../ui/section-helper';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
|
||||
export function CacheSettings() {
|
||||
const clearCache = useClearCache();
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Cache settings" />}
|
||||
description={
|
||||
<Trans message="Select cache provider and manually clear cache." />
|
||||
}
|
||||
>
|
||||
<CacheSelect />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="primary"
|
||||
disabled={clearCache.isPending}
|
||||
onClick={() => {
|
||||
clearCache.mutate();
|
||||
}}
|
||||
>
|
||||
<Trans message="Clear cache" />
|
||||
</Button>
|
||||
<SectionHelper
|
||||
color="warning"
|
||||
className="mt-30"
|
||||
description={
|
||||
<Trans
|
||||
message={
|
||||
'"File" is the best option for most cases and should not be changed, unless you are familiar with another cache method and have it set up on the server already.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function CacheSelect() {
|
||||
const {watch, clearErrors} = useFormContext<AdminSettings>();
|
||||
const cacheDriver = watch('server.cache_driver');
|
||||
|
||||
let CredentialSection: ComponentType<CredentialProps> | null = null;
|
||||
if (cacheDriver === 'memcached') {
|
||||
CredentialSection = MemcachedCredentials;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup separatorTop={false} name="cache_group">
|
||||
{isInvalid => {
|
||||
return (
|
||||
<>
|
||||
<FormSelect
|
||||
invalid={isInvalid}
|
||||
onSelectionChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
selectionMode="single"
|
||||
name="server.cache_driver"
|
||||
label={<Trans message="Cache method" />}
|
||||
description={
|
||||
<Trans message="Which method should be used for storing and retrieving cached items." />
|
||||
}
|
||||
>
|
||||
<Option value="file">
|
||||
<Trans message="File (Default)" />
|
||||
</Option>
|
||||
<Option value="array">
|
||||
<Trans message="None" />
|
||||
</Option>
|
||||
<Option value="apc">APC</Option>
|
||||
<Option value="memcached">Memcached</Option>
|
||||
<Option value="redis">Redis</Option>
|
||||
</FormSelect>
|
||||
{CredentialSection && (
|
||||
<div className="mt-30">
|
||||
<CredentialSection isInvalid={isInvalid} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
function MemcachedCredentials({isInvalid}: CredentialProps) {
|
||||
return (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.memcached_host"
|
||||
label={<Trans message="Memcached host" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
type="number"
|
||||
name="server.memcached_port"
|
||||
label={<Trans message="Memcached port" />}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
common/resources/client/admin/settings/pages/cache-settings/clear-cache.ts
Executable file
22
common/resources/client/admin/settings/pages/cache-settings/clear-cache.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
function clearCache(): Promise<Response> {
|
||||
return apiClient.post('cache/flush').then(r => r.data);
|
||||
}
|
||||
|
||||
export function useClearCache() {
|
||||
return useMutation({
|
||||
mutationFn: () => clearCache(),
|
||||
onSuccess: () => {
|
||||
toast(message('Cache cleared'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
142
common/resources/client/admin/settings/pages/gdpr-settings.tsx
Executable file
142
common/resources/client/admin/settings/pages/gdpr-settings.tsx
Executable file
@@ -0,0 +1,142 @@
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {SettingsSeparator} from '../settings-separator';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {FormSwitch} from '../../../ui/forms/toggle/switch';
|
||||
import {useFieldArray, useFormContext} from 'react-hook-form';
|
||||
import {AdminSettings} from '../admin-settings';
|
||||
import React, {Fragment} from 'react';
|
||||
import {FormSelect} from '../../../ui/forms/select/select';
|
||||
import {Item} from '../../../ui/forms/listbox/item';
|
||||
import {MenuItemForm} from '../../menus/menu-item-form';
|
||||
import {Button} from '../../../ui/buttons/button';
|
||||
import {AddIcon} from '../../../icons/material/Add';
|
||||
import {DialogTrigger} from '../../../ui/overlays/dialog/dialog-trigger';
|
||||
import {AddMenuItemDialog} from '../../appearance/sections/menus/add-menu-item-dialog';
|
||||
import {Accordion, AccordionItem} from '../../../ui/accordion/accordion';
|
||||
import {IconButton} from '../../../ui/buttons/icon-button';
|
||||
import {CloseIcon} from '../../../icons/material/Close';
|
||||
|
||||
export function GdprSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="GDPR" />}
|
||||
description={
|
||||
<Trans message="Configure settings related to EU General Data Protection Regulation." />
|
||||
}
|
||||
>
|
||||
<CookieNoticeSection />
|
||||
<SettingsSeparator />
|
||||
<RegistrationPoliciesSection />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieNoticeSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const noticeEnabled = watch('client.cookie_notice.enable');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormSwitch
|
||||
name="client.cookie_notice.enable"
|
||||
className="mb-20"
|
||||
description={
|
||||
<Trans message="Whether cookie notice should be shown automatically to users from EU until it is accepted." />
|
||||
}
|
||||
>
|
||||
<Trans message="Enable cookie notice" />
|
||||
</FormSwitch>
|
||||
{noticeEnabled && (
|
||||
<Fragment>
|
||||
<div className="mb-20 border-b pb-6">
|
||||
<div className="mb-20 border-b pb-10 text-sm font-medium">
|
||||
<Trans message="Information button" />
|
||||
</div>
|
||||
<MenuItemForm
|
||||
hideRoleAndPermissionFields
|
||||
formPathPrefix="client.cookie_notice.button"
|
||||
/>
|
||||
</div>
|
||||
<FormSelect
|
||||
name="client.cookie_notice.position"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Cookie notice position" />}
|
||||
className="mb-20"
|
||||
>
|
||||
<Item value="top">
|
||||
<Trans message="Top" />
|
||||
</Item>
|
||||
<Item value="bottom">
|
||||
<Trans message="Bottom" />
|
||||
</Item>
|
||||
</FormSelect>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationPoliciesSection() {
|
||||
const {fields, append, remove} = useFieldArray<
|
||||
AdminSettings,
|
||||
'client.registration.policies'
|
||||
>({
|
||||
name: 'client.registration.policies',
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="mb-6 text-sm">
|
||||
<Trans message="Registration policies" />
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
<Trans message="Create policies that will be shown on registration page. User will be required to accept them by toggling a checkbox." />
|
||||
</div>
|
||||
<Accordion className="mt-16" variant="outline">
|
||||
{fields.map((field, index) => (
|
||||
<AccordionItem
|
||||
key={field.id}
|
||||
label={field.label}
|
||||
chevronPosition="left"
|
||||
endAppend={
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItemForm
|
||||
hideRoleAndPermissionFields
|
||||
formPathPrefix={`client.register_policies.${index}`}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={value => {
|
||||
if (value) {
|
||||
append(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="mt-12"
|
||||
variant="link"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
size="xs"
|
||||
>
|
||||
<Trans message="Add another policy" />
|
||||
</Button>
|
||||
<AddMenuItemDialog title={<Trans message="Add policy" />} />
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
193
common/resources/client/admin/settings/pages/general-settings.tsx
Executable file
193
common/resources/client/admin/settings/pages/general-settings.tsx
Executable file
@@ -0,0 +1,193 @@
|
||||
import {useAdminSettings} from '../requests/use-admin-settings';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {FormSelect, Option} from '../../../ui/forms/select/select';
|
||||
import {FormSwitch} from '@common/ui/forms/toggle/switch';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useGenerateSitemap} from '../generate-sitemap';
|
||||
import {ExternalLink} from '@common/ui/buttons/external-link';
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {SettingsSeparator} from '../settings-separator';
|
||||
import {LearnMoreLink} from '../learn-more-link';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Fragment, useContext} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {useBootstrapData} from '@common/core/bootstrap-data/bootstrap-data-context';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {AdminSettingsWithFiles} from '@common/admin/settings/requests/update-admin-settings';
|
||||
|
||||
export function GeneralSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="General" />}
|
||||
description={
|
||||
<Trans message="Configure site url, homepage, theme and other general settings." />
|
||||
}
|
||||
>
|
||||
<SiteUrlSection />
|
||||
<SettingsSeparator />
|
||||
<HomepageSection />
|
||||
<SettingsSeparator />
|
||||
<ThemeSection />
|
||||
<SettingsSeparator />
|
||||
<SitemapSection />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteUrlSection() {
|
||||
const {data} = useAdminSettings();
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
let append = null;
|
||||
const server = data!.server;
|
||||
const isInvalid = server.newAppUrl && server.newAppUrl !== server.app_url;
|
||||
if (isInvalid) {
|
||||
append = (
|
||||
<div className="mt-20 text-sm text-danger">
|
||||
<Trans
|
||||
values={{
|
||||
baseUrl: server.app_url,
|
||||
currentUrl: server.newAppUrl,
|
||||
b: chunks => <b>{chunks}</b>,
|
||||
}}
|
||||
message="Base site url is set as <b>:baseUrl</b> in configuration, but current url is <b>:currentUrl</b>. It is recommended to set the primary url you want to use in configuration file and then redirect all other url versions to this primary version via cpanel or .htaccess file."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={!!isInvalid}
|
||||
name="server.app_url"
|
||||
label={<Trans message="Primary site url" />}
|
||||
description={
|
||||
<LearnMoreLink link="https://support.vebto.com/hc/articles/35/primary-site-url" />
|
||||
}
|
||||
/>
|
||||
{append}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function HomepageSection() {
|
||||
const {watch} = useFormContext<AdminSettingsWithFiles>();
|
||||
const {homepage} = useContext(SiteConfigContext);
|
||||
const {data} = useValueLists(['menuItemCategories']);
|
||||
const selectedType = watch('client.homepage.type');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormSelect
|
||||
name="client.homepage.type"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Site home page" />}
|
||||
description={
|
||||
<Trans message="Which page should be used as site homepage." />
|
||||
}
|
||||
>
|
||||
{homepage.options.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<Trans {...option.label} />
|
||||
</Option>
|
||||
))}
|
||||
{data?.menuItemCategories?.map(category => (
|
||||
<Option key={category.type} value={category.type}>
|
||||
{category.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
{data?.menuItemCategories?.map(category => {
|
||||
return selectedType === category.type ? (
|
||||
<FormSelect
|
||||
className="mt-24"
|
||||
name="client.homepage.value"
|
||||
key={category.name}
|
||||
selectionMode="single"
|
||||
label={
|
||||
<Trans message="Homepage :name" values={{name: category.name}} />
|
||||
}
|
||||
>
|
||||
{category.items.map(item => (
|
||||
<Option key={item.label} value={item.model_id}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSection() {
|
||||
const {
|
||||
data: {themes},
|
||||
} = useBootstrapData();
|
||||
return (
|
||||
<Fragment>
|
||||
<FormSelect
|
||||
className="mb-20"
|
||||
name="client.themes.default_id"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Default site theme" />}
|
||||
description={
|
||||
<Trans message="Which theme to use for users that have not chosen a theme manually." />
|
||||
}
|
||||
>
|
||||
<Option value={0}>
|
||||
<Trans message="System" />
|
||||
</Option>
|
||||
{themes.all.map(theme => (
|
||||
<Option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<FormSwitch
|
||||
name="client.themes.user_change"
|
||||
description={
|
||||
<Trans message="Allow users to manually change site theme." />
|
||||
}
|
||||
>
|
||||
<Trans message="Allow theme change" />
|
||||
</FormSwitch>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function SitemapSection() {
|
||||
const generateSitemap = useGenerateSitemap();
|
||||
const {base_url} = useSettings();
|
||||
|
||||
const url = `${base_url}/storage/sitemaps/sitemap-index.xml`;
|
||||
const link = <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="primary"
|
||||
disabled={generateSitemap.isPending}
|
||||
onClick={() => {
|
||||
generateSitemap.mutate();
|
||||
}}
|
||||
>
|
||||
<Trans message="Generate sitemap" />
|
||||
</Button>
|
||||
<div className="mt-14 text-sm text-muted">
|
||||
<Trans
|
||||
message="Once generated, sitemap url will be: :url"
|
||||
values={{
|
||||
url: link,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
common/resources/client/admin/settings/pages/localization-settings.tsx
Executable file
98
common/resources/client/admin/settings/pages/localization-settings.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import {FormSelect, Option} from '../../../ui/forms/select/select';
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {useValueLists} from '../../../http/value-lists';
|
||||
import {Section} from '../../../ui/forms/listbox/section';
|
||||
import {FormRadio} from '../../../ui/forms/radio-group/radio';
|
||||
import {FormRadioGroup} from '../../../ui/forms/radio-group/radio-group';
|
||||
import {DateFormatPresets, FormattedDate} from '../../../i18n/formatted-date';
|
||||
import {FormSwitch} from '../../../ui/forms/toggle/switch';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {useCurrentDateTime} from '../../../i18n/use-current-date-time';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
export function LocalizationSettings() {
|
||||
const {data} = useValueLists(['timezones', 'localizations']);
|
||||
const today = useCurrentDateTime();
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Localization" />}
|
||||
description={
|
||||
<Trans message="Configure global date, time and language settings." />
|
||||
}
|
||||
>
|
||||
<FormSelect
|
||||
className="mb-30"
|
||||
required
|
||||
name="client.dates.default_timezone"
|
||||
showSearchField
|
||||
selectionMode="single"
|
||||
label={<Trans message="Default timezone" />}
|
||||
searchPlaceholder={trans(message('Search timezones'))}
|
||||
description={
|
||||
<Trans message="Which timezone should be selected by default for new users and guests." />
|
||||
}
|
||||
>
|
||||
<Option key="auto" value="auto">
|
||||
<Trans message="Auto" />
|
||||
</Option>
|
||||
{Object.entries(data?.timezones || {}).map(([groupName, timezones]) => (
|
||||
<Section key={groupName} label={groupName}>
|
||||
{timezones.map(timezone => (
|
||||
<Option key={timezone.value} value={timezone.value}>
|
||||
{timezone.text}
|
||||
</Option>
|
||||
))}
|
||||
</Section>
|
||||
))}
|
||||
</FormSelect>
|
||||
<FormSelect
|
||||
name="client.locale.default"
|
||||
className="mb-30"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Default language" />}
|
||||
description={
|
||||
<Trans message="Which localization should be selected by default for new users and guests." />
|
||||
}
|
||||
>
|
||||
<Option key="auto" value="auto">
|
||||
<Trans message="Auto" />
|
||||
</Option>
|
||||
{(data?.localizations || []).map(locale => (
|
||||
<Option key={locale.language} value={locale.language} capitalizeFirst>
|
||||
{locale.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<FormRadioGroup
|
||||
required
|
||||
className="mb-30"
|
||||
size="sm"
|
||||
name="client.dates.format"
|
||||
orientation="vertical"
|
||||
label={<Trans message="Date verbosity" />}
|
||||
description={
|
||||
<Trans message="Default verbosity for all dates displayed across the site. Month/day order and separators will be adjusted automatically, based on user's locale." />
|
||||
}
|
||||
>
|
||||
<FormRadio key="auto" value="auto">
|
||||
<Trans message="Auto" />
|
||||
</FormRadio>
|
||||
{Object.entries(DateFormatPresets).map(([format, options]) => (
|
||||
<FormRadio key={format} value={format}>
|
||||
<FormattedDate date={today} options={options} />
|
||||
</FormRadio>
|
||||
))}
|
||||
</FormRadioGroup>
|
||||
<FormSwitch
|
||||
name="client.i18n.enable"
|
||||
description={
|
||||
<Trans message="If disabled, site will always be shown in default language and user will not be able to change their locale." />
|
||||
}
|
||||
>
|
||||
<Trans message="Enable translations" />
|
||||
</FormSwitch>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
60
common/resources/client/admin/settings/pages/logging-settings.tsx
Executable file
60
common/resources/client/admin/settings/pages/logging-settings.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {SettingsPanel} from '@common/admin/settings/settings-panel';
|
||||
import {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {SectionHelper} from '@common/ui/section-helper';
|
||||
import {ExternalLink} from '@common/ui/buttons/external-link';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
export function LoggingSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Error logging" />}
|
||||
description={
|
||||
<Trans message="Configure site error logging and related 3rd party integrations." />
|
||||
}
|
||||
>
|
||||
<SentrySection />
|
||||
<SectionHelper
|
||||
className="mt-30"
|
||||
color="positive"
|
||||
description={
|
||||
<Trans
|
||||
values={{
|
||||
a: parts => (
|
||||
<ExternalLink href="https://sentry.io">{parts}</ExternalLink>
|
||||
),
|
||||
}}
|
||||
message="<a>Sentry</a> integration provides real-time error tracking and helps identify and fix issues when site is in production."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SentrySection() {
|
||||
const {clearErrors} = useFormContext();
|
||||
return (
|
||||
<SettingsErrorGroup
|
||||
separatorTop={false}
|
||||
separatorBottom={false}
|
||||
name="logging_group"
|
||||
>
|
||||
{isInvalid => {
|
||||
return (
|
||||
<FormTextField
|
||||
onChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
name="server.sentry_dsn"
|
||||
type="url"
|
||||
minLength={30}
|
||||
label={<Trans message="Sentry DSN" />}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {AdminSettings} from '../../admin-settings';
|
||||
import {useSocialLogin} from '../../../../auth/requests/use-social-login';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {GmailIcon} from './gmail-icon';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
export function ConnectGmailPanel() {
|
||||
const {watch, setValue} = useFormContext<AdminSettings>();
|
||||
const {connectSocial} = useSocialLogin();
|
||||
const connectedEmail = watch('server.connectedGmailAccount');
|
||||
|
||||
const handleGmailConnect = async () => {
|
||||
const e = await connectSocial('secure/settings/mail/gmail/connect');
|
||||
if (e?.status === 'SUCCESS') {
|
||||
const email = (e.callbackData as any).profile.email;
|
||||
setValue('server.connectedGmailAccount', email);
|
||||
toast(message('Connected gmail account: :email', {values: {email}}));
|
||||
}
|
||||
};
|
||||
|
||||
const connectButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<GmailIcon />}
|
||||
onClick={() => {
|
||||
handleGmailConnect();
|
||||
}}
|
||||
>
|
||||
<Trans message="Connect gmail account" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const reconnectPanel = (
|
||||
<div className="flex items-center gap-14 rounded border bg-alt px-14 py-6 text-sm">
|
||||
<GmailIcon size="lg" />
|
||||
{connectedEmail}
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
handleGmailConnect();
|
||||
}}
|
||||
>
|
||||
<Trans message="Reconnect" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="mb-6 text-sm">
|
||||
<Trans message="Gmail account" />
|
||||
</div>
|
||||
{connectedEmail ? reconnectPanel : connectButton}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
33
common/resources/client/admin/settings/pages/mail-settings/gmail-icon.tsx
Executable file
33
common/resources/client/admin/settings/pages/mail-settings/gmail-icon.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import {createSvgIcon} from '../../../../icons/create-svg-icon';
|
||||
|
||||
export const GmailIcon = createSvgIcon(
|
||||
[
|
||||
<path
|
||||
key="0"
|
||||
fill="#4caf50"
|
||||
d="M45,16.2l-5,2.75l-5,4.75L35,40h7c1.657,0,3-1.343,3-3V16.2z"
|
||||
/>,
|
||||
<path
|
||||
key="1"
|
||||
fill="#1e88e5"
|
||||
d="M3,16.2l3.614,1.71L13,23.7V40H6c-1.657,0-3-1.343-3-3V16.2z"
|
||||
/>,
|
||||
<polygon
|
||||
key="2"
|
||||
fill="#e53935"
|
||||
points="35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17"
|
||||
/>,
|
||||
<path
|
||||
key="3"
|
||||
fill="#c62828"
|
||||
d="M3,12.298V16.2l10,7.5V11.2L9.876,8.859C9.132,8.301,8.228,8,7.298,8h0C4.924,8,3,9.924,3,12.298z"
|
||||
/>,
|
||||
<path
|
||||
key="4"
|
||||
fill="#fbc02d"
|
||||
d="M45,12.298V16.2l-10,7.5V11.2l3.124-2.341C38.868,8.301,39.772,8,40.702,8h0 C43.076,8,45,9.924,45,12.298z"
|
||||
/>,
|
||||
],
|
||||
'Gmail',
|
||||
'0 0 48 48'
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import {Fragment} from 'react';
|
||||
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
|
||||
export interface MailgunCredentialsProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
export function MailgunCredentials({isInvalid}: MailgunCredentialsProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.mailgun_domain"
|
||||
label={<Trans message="Mailgun domain" />}
|
||||
description={
|
||||
<Trans message="Usually the domain of your site (site.com)" />
|
||||
}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.mailgun_secret"
|
||||
label={<Trans message="Mailgun API key" />}
|
||||
description={<Trans message="Should start with `key-`" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
name="server.mailgun_endpoint"
|
||||
label={<Trans message="Mailgun endpoint" />}
|
||||
description={
|
||||
<Trans message="Can be left empty, if your mailgun account is in the US region." />
|
||||
}
|
||||
placeholder="api.eu.mailgun.net"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {SettingsPanel} from '../../settings-panel';
|
||||
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {ExternalLink} from '../../../../ui/buttons/external-link';
|
||||
import {SectionHelper} from '../../../../ui/section-helper';
|
||||
import {SettingsSeparator} from '../../settings-separator';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {OutgoingMailGroup} from './outgoing-mail-group';
|
||||
import {useSettings} from '../../../../core/settings/use-settings';
|
||||
|
||||
export function OutgoingEmailSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Outgoing email settings" />}
|
||||
description={
|
||||
<Trans message="Change outgoing email handlers, email credentials and other related settings." />
|
||||
}
|
||||
>
|
||||
<FormTextField
|
||||
id="outgoing-emails"
|
||||
className="mb-30"
|
||||
type="email"
|
||||
name="server.mail_from_address"
|
||||
label={<Trans message="From address" />}
|
||||
description={
|
||||
<Trans message="All outgoing application emails will be sent from this email address." />
|
||||
}
|
||||
required
|
||||
/>
|
||||
<ContactAddressSection />
|
||||
<FormTextField
|
||||
className="mb-30"
|
||||
name="server.mail_from_name"
|
||||
label={<Trans message="From name" />}
|
||||
description={
|
||||
<Trans message="All outgoing application emails will be sent using this name." />
|
||||
}
|
||||
required
|
||||
/>
|
||||
<SectionHelper
|
||||
color="warning"
|
||||
description={
|
||||
<Trans message="Your selected mail method must be authorized to send emails using this address and name." />
|
||||
}
|
||||
/>
|
||||
<SettingsSeparator />
|
||||
<OutgoingMailGroup />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactAddressSection() {
|
||||
const {base_url} = useSettings();
|
||||
const contactPageUrl = `${base_url}/contact`;
|
||||
const link = (
|
||||
<ExternalLink href={contactPageUrl}>{contactPageUrl}</ExternalLink>
|
||||
);
|
||||
return (
|
||||
<FormTextField
|
||||
className="mb-30"
|
||||
type="email"
|
||||
name="client.mail.contact_page_address"
|
||||
label={<Trans message="Contact page address" />}
|
||||
description={
|
||||
<Trans
|
||||
values={{
|
||||
contactPageUrl: link,
|
||||
}}
|
||||
message="Where emails from :contactPageUrl page should be sent to."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {AdminSettings} from '../../admin-settings';
|
||||
import {ComponentType, Fragment} from 'react';
|
||||
import {MailgunCredentials} from './mailgun-credentials';
|
||||
import {SmtpCredentials} from './smtp-credentials';
|
||||
import {SesCredentials} from './ses-credentials';
|
||||
import {PostmarkCredentials} from './postmark-credentials';
|
||||
import {ConnectGmailPanel} from './connect-gmail-panel';
|
||||
import {SettingsErrorGroup} from '../../settings-error-group';
|
||||
import {FormSelect, Option} from '../../../../ui/forms/select/select';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {LearnMoreLink} from '../../learn-more-link';
|
||||
|
||||
export function OutgoingMailGroup() {
|
||||
const {watch, clearErrors} = useFormContext<AdminSettings>();
|
||||
|
||||
const selectedDriver = watch('server.mail_driver');
|
||||
const credentialForms: ComponentType<{isInvalid: boolean}>[] = [];
|
||||
|
||||
if (selectedDriver === 'mailgun') {
|
||||
credentialForms.push(MailgunCredentials);
|
||||
}
|
||||
if (selectedDriver === 'smtp') {
|
||||
credentialForms.push(SmtpCredentials);
|
||||
}
|
||||
if (selectedDriver === 'ses') {
|
||||
credentialForms.push(SesCredentials);
|
||||
}
|
||||
if (selectedDriver === 'postmark') {
|
||||
credentialForms.push(PostmarkCredentials);
|
||||
}
|
||||
if (selectedDriver === 'gmailApi') {
|
||||
credentialForms.push(ConnectGmailPanel);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup
|
||||
separatorTop={false}
|
||||
separatorBottom={false}
|
||||
name="mail_group"
|
||||
>
|
||||
{isInvalid => (
|
||||
<Fragment>
|
||||
<FormSelect
|
||||
onSelectionChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
selectionMode="single"
|
||||
name="server.mail_driver"
|
||||
label={<Trans message="Outgoing mail method" />}
|
||||
description={
|
||||
<div>
|
||||
<Trans message="Which method should be used for sending outgoing application emails (like registration confirmation)" />
|
||||
<LearnMoreLink
|
||||
className="mt-8"
|
||||
link="https://support.vebto.com/hc/articles/42/44/155/incoming-emails"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Option value="mailgun">Mailgun</Option>
|
||||
<Option value="gmailApi">Gmail Api</Option>
|
||||
<Option value="smtp">SMTP</Option>
|
||||
<Option value="postmark">Postmark</Option>
|
||||
<Option value="ses">Ses (Amazon Simple Email Service)</Option>
|
||||
<Option value="sendmail">SendMail</Option>
|
||||
<Option value="log">Log (Email will be saved to error log)</Option>
|
||||
</FormSelect>
|
||||
{credentialForms.length ? (
|
||||
<div className="mt-30">
|
||||
{credentialForms.map((CredentialsForm, index) => (
|
||||
<CredentialsForm key={index} isInvalid={isInvalid} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
|
||||
export interface PostmarkCredentialsProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
export function PostmarkCredentials({isInvalid}: PostmarkCredentialsProps) {
|
||||
return (
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
name="server.postmark_token"
|
||||
label={<Trans message="Postmark token" />}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
export interface SesCredentialsProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
export function SesCredentials({isInvalid}: SesCredentialsProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.ses_key"
|
||||
label={<Trans message="SES key" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.ses_secret"
|
||||
label={<Trans message="SES secret" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
name="server.ses_region"
|
||||
label={<Trans message="SES region" />}
|
||||
placeholder="us-east-1"
|
||||
required
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {FormSelect} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
|
||||
export interface SmtpCredentialsProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
export function SmtpCredentials({isInvalid}: SmtpCredentialsProps) {
|
||||
return (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.mail_host"
|
||||
label={<Trans message="SMTP host" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.mail_username"
|
||||
label={<Trans message="SMTP username" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
type="password"
|
||||
name="server.mail_password"
|
||||
label={<Trans message="SMTP password" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
type="number"
|
||||
name="server.mail_port"
|
||||
label={<Trans message="SMTP port" />}
|
||||
/>
|
||||
<FormSelect
|
||||
selectionMode="single"
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.mail_encryption"
|
||||
label={<Trans message="SMTP encryption" />}
|
||||
>
|
||||
<Item value="">
|
||||
<Trans message="None" />
|
||||
</Item>
|
||||
<Item value="tls">
|
||||
<Trans message="TLS" />
|
||||
</Item>
|
||||
</FormSelect>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
common/resources/client/admin/settings/pages/queue-settings.tsx
Executable file
132
common/resources/client/admin/settings/pages/queue-settings.tsx
Executable file
@@ -0,0 +1,132 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {ComponentType} from 'react';
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {SettingsErrorGroup} from '../settings-error-group';
|
||||
import {SectionHelper} from '../../../ui/section-helper';
|
||||
import {AdminSettings} from '../admin-settings';
|
||||
import {FormSelect, Option} from '../../../ui/forms/select/select';
|
||||
import {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
|
||||
export function QueueSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Queue" />}
|
||||
description={
|
||||
<Trans message="Select active queue method and enter related 3rd party API keys." />
|
||||
}
|
||||
>
|
||||
<SectionHelper
|
||||
color="positive"
|
||||
className="mb-30"
|
||||
description={
|
||||
<Trans message="Queues allow to defer time consuming tasks, such as sending an email, until a later time. Deferring these tasks can speed up web requests to the application." />
|
||||
}
|
||||
/>
|
||||
<SectionHelper
|
||||
color="warning"
|
||||
className="mb-30"
|
||||
description={
|
||||
<Trans message="All methods except sync require additional setup, which should be performed before changing the queue method. Consult documentation for more information." />
|
||||
}
|
||||
/>
|
||||
<DriverSection />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverSection() {
|
||||
const {watch, clearErrors} = useFormContext<AdminSettings>();
|
||||
const queueDriver = watch('server.queue_driver');
|
||||
|
||||
let CredentialSection: ComponentType<CredentialProps> | null = null;
|
||||
if (queueDriver === 'sqs') {
|
||||
CredentialSection = SqsCredentials;
|
||||
}
|
||||
return (
|
||||
<SettingsErrorGroup
|
||||
separatorTop={false}
|
||||
separatorBottom={false}
|
||||
name="queue_group"
|
||||
>
|
||||
{isInvalid => {
|
||||
return (
|
||||
<>
|
||||
<FormSelect
|
||||
invalid={isInvalid}
|
||||
onSelectionChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
selectionMode="single"
|
||||
name="server.queue_driver"
|
||||
label={<Trans message="Queue method" />}
|
||||
required
|
||||
>
|
||||
<Option value="sync">
|
||||
<Trans message="Sync (Default)" />
|
||||
</Option>
|
||||
<Option value="beanstalkd">Beanstalkd</Option>
|
||||
<Option value="database">
|
||||
<Trans message="Database" />
|
||||
</Option>
|
||||
<Option value="sqs">
|
||||
<Trans message="SQS (Amazon simple queue service)" />
|
||||
</Option>
|
||||
<Option value="redis">Redis</Option>
|
||||
</FormSelect>
|
||||
{CredentialSection && (
|
||||
<div className="mt-30">
|
||||
<CredentialSection isInvalid={isInvalid} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
function SqsCredentials({isInvalid}: CredentialProps) {
|
||||
return (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.sqs_queue_key"
|
||||
label={<Trans message="SQS queue key" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.sqs_queue_secret"
|
||||
label={<Trans message="SQS queue secret" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.sqs_queue_prefix"
|
||||
label={<Trans message="SQS queue prefix" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.sqs_queue_name"
|
||||
label={<Trans message="SQS queue name" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.sqs_queue_region"
|
||||
label={<Trans message="SQS queue region" />}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
common/resources/client/admin/settings/pages/recaptcha-settings.tsx
Executable file
88
common/resources/client/admin/settings/pages/recaptcha-settings.tsx
Executable file
@@ -0,0 +1,88 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {useContext} from 'react';
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {SettingsErrorGroup} from '../settings-error-group';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {FormSwitch} from '@common/ui/forms/toggle/switch';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
export function RecaptchaSettings() {
|
||||
const {settings} = useContext(SiteConfigContext);
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Recaptcha" />}
|
||||
description={
|
||||
<Trans message="Configure google recaptcha integration and credentials." />
|
||||
}
|
||||
>
|
||||
{settings?.showRecaptchaLinkSwitch && (
|
||||
<FormSwitch
|
||||
className="mb-30"
|
||||
name="client.recaptcha.enable.link_creation"
|
||||
description={
|
||||
<Trans message="Enable recaptcha integration when creating links from homepage or user dashboard." />
|
||||
}
|
||||
>
|
||||
<Trans message="Link creation" />
|
||||
</FormSwitch>
|
||||
)}
|
||||
<FormSwitch
|
||||
className="mb-30"
|
||||
name="client.recaptcha.enable.contact"
|
||||
description={
|
||||
<Trans
|
||||
message={'Enable recaptcha integration for "contact us" page.'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trans message="Contact page" />
|
||||
</FormSwitch>
|
||||
<FormSwitch
|
||||
className="mb-30"
|
||||
name="client.recaptcha.enable.register"
|
||||
description={
|
||||
<Trans message="Enable recaptcha integration for registration page." />
|
||||
}
|
||||
>
|
||||
<Trans message="Registration page" />
|
||||
</FormSwitch>
|
||||
<RecaptchaSection />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function RecaptchaSection() {
|
||||
const {clearErrors} = useFormContext();
|
||||
return (
|
||||
<SettingsErrorGroup
|
||||
separatorTop={false}
|
||||
separatorBottom={false}
|
||||
name="recaptcha_group"
|
||||
>
|
||||
{isInvalid => {
|
||||
return (
|
||||
<>
|
||||
<FormTextField
|
||||
className="mb-30"
|
||||
onChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
name="client.recaptcha.site_key"
|
||||
label={<Trans message="Recaptcha v3 site key" />}
|
||||
/>
|
||||
<FormTextField
|
||||
onChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
name="client.recaptcha.secret_key"
|
||||
label={<Trans message="Recaptcha v3 secret key" />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
78
common/resources/client/admin/settings/pages/reports-settings.tsx
Executable file
78
common/resources/client/admin/settings/pages/reports-settings.tsx
Executable file
@@ -0,0 +1,78 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {SettingsErrorGroup} from '../settings-error-group';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {FormFileField} from '@common/ui/forms/input-field/file-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
export function ReportsSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Analytics" />}
|
||||
description={
|
||||
<Trans message="Configure google analytics integration and credentials." />
|
||||
}
|
||||
>
|
||||
<AnalyticsSection />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsSection() {
|
||||
const {clearErrors} = useFormContext();
|
||||
return (
|
||||
<SettingsErrorGroup
|
||||
separatorTop={false}
|
||||
separatorBottom={false}
|
||||
name="analytics_group"
|
||||
>
|
||||
{isInvalid => (
|
||||
<Fragment>
|
||||
<FormFileField
|
||||
className="mb-30"
|
||||
onChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
name="files.certificate"
|
||||
accept=".json"
|
||||
label={<Trans message="Google service account key file (.json)" />}
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-30"
|
||||
onChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
name="server.analytics_property_id"
|
||||
type="number"
|
||||
label={<Trans message="Google analytics property ID" />}
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-30"
|
||||
onChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
invalid={isInvalid}
|
||||
name="client.analytics.tracking_code"
|
||||
placeholder="G-******"
|
||||
min="1"
|
||||
max="20"
|
||||
description={
|
||||
<Trans message="Google analytics measurement ID only, not the whole javascript snippet." />
|
||||
}
|
||||
label={<Trans message="Google tag manager measurement ID" />}
|
||||
/>
|
||||
<FormTextField
|
||||
name="client.analytics.gchart_api_key"
|
||||
label={<Trans message="Google maps javascript API key" />}
|
||||
description={
|
||||
<Trans message="Only required in order to show world geochart on integrated analytics pages." />
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Payload {
|
||||
model: string;
|
||||
driver: string;
|
||||
}
|
||||
|
||||
export function useImportSearchModels() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => importModels(payload),
|
||||
onSuccess: () => {
|
||||
toast(trans(message('Imported search models')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function importModels(payload: Payload): Promise<Response> {
|
||||
return apiClient.post('admin/search/import', payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
models: {model: string; name: string}[];
|
||||
}
|
||||
|
||||
export function useSearchModels() {
|
||||
return useQuery({
|
||||
queryKey: ['search-models'],
|
||||
queryFn: () => fetchModels(),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchModels(): Promise<Response> {
|
||||
return apiClient.get('admin/search/models').then(response => response.data);
|
||||
}
|
||||
214
common/resources/client/admin/settings/pages/search-settings/search-settings.tsx
Executable file
214
common/resources/client/admin/settings/pages/search-settings/search-settings.tsx
Executable file
@@ -0,0 +1,214 @@
|
||||
import {FormSelect, Select} from '@common/ui/forms/select/select';
|
||||
import {SettingsPanel} from '../../settings-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {AdminSettingsWithFiles} from '@common/admin/settings/requests/update-admin-settings';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {SectionHelper} from '@common/ui/section-helper';
|
||||
import {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';
|
||||
import {Fragment, useState} from 'react';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useSearchModels} from '@common/admin/settings/pages/search-settings/requests/use-search-models';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useImportSearchModels} from '@common/admin/settings/pages/search-settings/requests/use-import-search-models';
|
||||
|
||||
export function SearchSettings() {
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Search" />}
|
||||
description={
|
||||
<Trans message="Configure search method used on the site as well as related 3rd party integrations." />
|
||||
}
|
||||
>
|
||||
<SearchMethodSelect />
|
||||
<ImportRecordsPanel />
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchMethodSelect() {
|
||||
const {watch} = useFormContext<AdminSettingsWithFiles>();
|
||||
const selectedMethod = watch('server.scout_driver');
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup name="search_group" separatorBottom={false}>
|
||||
{isInvalid => (
|
||||
<Fragment>
|
||||
<FormSelect
|
||||
invalid={isInvalid}
|
||||
name="server.scout_driver"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Search method" />}
|
||||
description={
|
||||
<Trans message="Which method should be used for search related functionality across the site." />
|
||||
}
|
||||
>
|
||||
<Item value="mysql">Mysql</Item>
|
||||
<Item value="meilisearch">Meilisearch</Item>
|
||||
<Item value="tntsearch">TNTSearch</Item>
|
||||
<Item value="Matchish\ScoutElasticSearch\Engines\ElasticSearchEngine">
|
||||
Elasticsearch
|
||||
</Item>
|
||||
<Item value="algolia">Algolia</Item>
|
||||
</FormSelect>
|
||||
{selectedMethod === 'mysql' && <MysqlFields />}
|
||||
{selectedMethod === 'meilisearch' && <MeilisearchFields />}
|
||||
{selectedMethod === 'algolia' && <AlgoliaFields />}
|
||||
{selectedMethod ===
|
||||
'Matchish\\ScoutElasticSearch\\Engines\\ElasticSearchEngine' && (
|
||||
<ElasticsearchField />
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function MysqlFields() {
|
||||
const {clearErrors} = useFormContext<AdminSettingsWithFiles>();
|
||||
return (
|
||||
<FormSelect
|
||||
className="mt-24"
|
||||
name="server.scout_mysql_mode"
|
||||
selectionMode="single"
|
||||
label={<Trans message="MySQL mode" />}
|
||||
onSelectionChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
>
|
||||
<Item value="basic">
|
||||
<Trans message="Basic" />
|
||||
</Item>
|
||||
<Item value="extended">
|
||||
<Trans message="Extended" />
|
||||
</Item>
|
||||
<Item value="fulltext">
|
||||
<Trans message="Fulltext" />
|
||||
</Item>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function MeilisearchFields() {
|
||||
return (
|
||||
<SectionHelper
|
||||
className="mt-24"
|
||||
color="warning"
|
||||
title={<Trans message="Important!" />}
|
||||
description={
|
||||
<Trans
|
||||
message="<a>Meilisearch</a> needs to be installed and running for this method to work."
|
||||
values={{
|
||||
a: parts => (
|
||||
<a
|
||||
href="https://www.meilisearch.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{parts}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ElasticsearchField() {
|
||||
return (
|
||||
<SectionHelper
|
||||
className="mt-24"
|
||||
color="warning"
|
||||
title={<Trans message="Important!" />}
|
||||
description={
|
||||
<Trans
|
||||
message="<a>Elasticsearch</a> needs to be installed and running for this method to work."
|
||||
values={{
|
||||
a: parts => (
|
||||
<a href="https://www.elastic.co" target="_blank" rel="noreferrer">
|
||||
{parts}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlgoliaFields() {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
className="mt-24"
|
||||
name="server.algolia_app_id"
|
||||
label={<Trans message="Algolia app ID" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mt-24"
|
||||
name="server.algolia_secret"
|
||||
label={<Trans message="Algolia app secret" />}
|
||||
required
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportRecordsPanel() {
|
||||
const {getValues} = useFormContext<AdminSettingsWithFiles>();
|
||||
const {data} = useSearchModels();
|
||||
const importModels = useImportSearchModels();
|
||||
const [selectedModel, setSelectedModel] = useState('*');
|
||||
return (
|
||||
<SectionHelper
|
||||
className="mt-34"
|
||||
color="neutral"
|
||||
title={<Trans message="Import records" />}
|
||||
description={
|
||||
<span>
|
||||
<Trans message="Whenever a new search method is enabled, records that already exist in database need to be imported into the index. All records created after search method is enabled will be imported automatically." />
|
||||
<br />
|
||||
<br />
|
||||
<Trans message="Depending on number of records in database, importing could take some time. Don't close this window while it is in progress." />
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<div className="mt-10 border-t pt-14">
|
||||
<Select
|
||||
selectionMode="single"
|
||||
label={<Trans message="What to import?" />}
|
||||
selectedValue={selectedModel}
|
||||
onSelectionChange={newValue => {
|
||||
setSelectedModel(newValue as string);
|
||||
}}
|
||||
>
|
||||
<Item value="*">
|
||||
<Trans message="Everything" />
|
||||
</Item>
|
||||
{data?.models.map(item => (
|
||||
<Item value={item.model} key={item.model}>
|
||||
<Trans message={item.name} />
|
||||
</Item>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
className="mb-8 mt-24"
|
||||
disabled={importModels.isPending}
|
||||
onClick={() => {
|
||||
importModels.mutate({
|
||||
model: selectedModel,
|
||||
driver: getValues('server.scout_driver')!,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Import now" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
192
common/resources/client/admin/settings/pages/subscription-settings.tsx
Executable file
192
common/resources/client/admin/settings/pages/subscription-settings.tsx
Executable file
@@ -0,0 +1,192 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {SettingsPanel} from '../settings-panel';
|
||||
import {FormSwitch} from '../../../ui/forms/toggle/switch';
|
||||
import {SettingsSeparator} from '../settings-separator';
|
||||
import {LearnMoreLink} from '../learn-more-link';
|
||||
import {AdminSettings} from '../admin-settings';
|
||||
import {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';
|
||||
import {SettingsErrorGroup} from '../settings-error-group';
|
||||
import {JsonChipField} from '../json-chip-field';
|
||||
import {Tabs} from '../../../ui/tabs/tabs';
|
||||
import {TabList} from '../../../ui/tabs/tab-list';
|
||||
import {Tab} from '../../../ui/tabs/tab';
|
||||
import {TabPanel, TabPanels} from '../../../ui/tabs/tab-panels';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
export function SubscriptionSettings() {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Subscriptions" />}
|
||||
description={
|
||||
<Trans message="Configure gateway integration, accepted cards, invoices and other related settings." />
|
||||
}
|
||||
>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Trans message="General" />
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Trans message="Invoices" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="pt-30">
|
||||
<TabPanel>
|
||||
<FormSwitch
|
||||
name="client.billing.enable"
|
||||
description={
|
||||
<Trans message="Enable or disable all subscription related functionality across the site." />
|
||||
}
|
||||
>
|
||||
<Trans message="Enable subscriptions" />
|
||||
</FormSwitch>
|
||||
<SettingsSeparator />
|
||||
<PaypalSection />
|
||||
<StripeSection />
|
||||
<SettingsSeparator />
|
||||
<JsonChipField
|
||||
label={<Trans message="Accepted cards" />}
|
||||
name="client.billing.accepted_cards"
|
||||
placeholder={trans({message: 'Add new card...'})}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<FormTextField
|
||||
inputElementType="textarea"
|
||||
rows={5}
|
||||
label={<Trans message="Invoice address" />}
|
||||
name="client.billing.invoice.address"
|
||||
className="mb-30"
|
||||
/>
|
||||
<FormTextField
|
||||
inputElementType="textarea"
|
||||
rows={5}
|
||||
label={<Trans message="Invoice notes" />}
|
||||
description={
|
||||
<Trans message="Default notes to show under `notes` section of user invoice. Optional." />
|
||||
}
|
||||
name="client.billing.invoice.notes"
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function PaypalSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const paypalIsEnabled = watch('client.billing.paypal.enable');
|
||||
return (
|
||||
<div className="mb-30">
|
||||
<FormSwitch
|
||||
name="client.billing.paypal.enable"
|
||||
description={
|
||||
<div>
|
||||
<Trans message="Enable PayPal payment gateway integration." />
|
||||
<LearnMoreLink
|
||||
className="mt-6"
|
||||
link="https://support.vebto.com/hc/articles/147/configuring-paypal"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Trans message="PayPal gateway" />
|
||||
</FormSwitch>
|
||||
{paypalIsEnabled ? (
|
||||
<SettingsErrorGroup name="paypal_group">
|
||||
{isInvalid => (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="server.paypal_client_id"
|
||||
label={<Trans message="PayPal Client ID" />}
|
||||
required
|
||||
invalid={isInvalid}
|
||||
className="mb-20"
|
||||
/>
|
||||
<FormTextField
|
||||
name="server.paypal_secret"
|
||||
label={<Trans message="PayPal Secret" />}
|
||||
required
|
||||
invalid={isInvalid}
|
||||
className="mb-20"
|
||||
/>
|
||||
<FormTextField
|
||||
name="server.paypal_webhook_id"
|
||||
label={<Trans message="PayPal Webhook ID" />}
|
||||
required
|
||||
invalid={isInvalid}
|
||||
className="mb-20"
|
||||
/>
|
||||
<FormSwitch
|
||||
name="client.billing.paypal_test_mode"
|
||||
invalid={isInvalid}
|
||||
description={
|
||||
<div>
|
||||
<Trans message="Allows testing PayPal payments with sandbox accounts." />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Trans message="PayPal test mode" />
|
||||
</FormSwitch>
|
||||
</Fragment>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StripeSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const stripeEnabled = watch('client.billing.stripe.enable');
|
||||
return (
|
||||
<Fragment>
|
||||
<FormSwitch
|
||||
name="client.billing.stripe.enable"
|
||||
description={
|
||||
<div>
|
||||
<Trans message="Enable Stripe payment gateway integration." />
|
||||
<LearnMoreLink
|
||||
className="mt-6"
|
||||
link="https://support.vebto.com/hc/articles/148/configuring-stripe"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Trans message="Stripe gateway" />
|
||||
</FormSwitch>
|
||||
{stripeEnabled ? (
|
||||
<SettingsErrorGroup name="stripe_group" separatorBottom={false}>
|
||||
{isInvalid => (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="server.stripe_key"
|
||||
label={<Trans message="Stripe publishable key" />}
|
||||
required
|
||||
className="mb-20"
|
||||
invalid={isInvalid}
|
||||
/>
|
||||
<FormTextField
|
||||
name="server.stripe_secret"
|
||||
label={<Trans message="Stripe secret key" />}
|
||||
required
|
||||
className="mb-20"
|
||||
invalid={isInvalid}
|
||||
/>
|
||||
<FormTextField
|
||||
name="server.stripe_webhook_secret"
|
||||
label={<Trans message="Stripe webhook signing secret" />}
|
||||
className="mb-20"
|
||||
invalid={isInvalid}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {Fragment} from 'react';
|
||||
import {FormTextField} from '../../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../../../../i18n/trans';
|
||||
import {CredentialFormProps} from '../uploading-settings';
|
||||
import {Button} from '../../../../../ui/buttons/button';
|
||||
import {Dialog} from '../../../../../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../../../../../ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '../../../../../ui/overlays/dialog/dialog-body';
|
||||
import {useForm, useFormContext} from 'react-hook-form';
|
||||
import {Form} from '../../../../../ui/forms/form';
|
||||
import {DialogTrigger} from '../../../../../ui/overlays/dialog/dialog-trigger';
|
||||
import {AdminSettings} from '../../../admin-settings';
|
||||
import {DialogFooter} from '../../../../../ui/overlays/dialog/dialog-footer';
|
||||
import {useDialogContext} from '../../../../../ui/overlays/dialog/dialog-context';
|
||||
import {useGenerateDropboxRefreshToken} from './use-generate-dropbox-refresh-token';
|
||||
|
||||
export function DropboxForm({isInvalid}: CredentialFormProps) {
|
||||
const {watch, setValue} = useFormContext<AdminSettings>();
|
||||
const appKey = watch('server.storage_dropbox_app_key');
|
||||
const appSecret = watch('server.storage_dropbox_app_secret');
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-20"
|
||||
name="server.storage_dropbox_app_key"
|
||||
label={<Trans message="Dropbox application key" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-20"
|
||||
name="server.storage_dropbox_app_secret"
|
||||
label={<Trans message="Dropbox application secret" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-20"
|
||||
name="server.storage_dropbox_refresh_token"
|
||||
label={<Trans message="Dropbox refresh token" />}
|
||||
required
|
||||
/>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={refreshToken => {
|
||||
if (refreshToken) {
|
||||
setValue('server.storage_dropbox_refresh_token', refreshToken);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
disabled={!appKey || !appSecret}
|
||||
>
|
||||
<Trans message="Get dropbox refresh token" />
|
||||
</Button>
|
||||
<DropboxRefreshTokenDialog appKey={appKey!} appSecret={appSecret!} />
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropboxRefreshTokenDialogProps {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
}
|
||||
function DropboxRefreshTokenDialog({
|
||||
appKey,
|
||||
appSecret,
|
||||
}: DropboxRefreshTokenDialogProps) {
|
||||
const form = useForm<{accessCode: string}>();
|
||||
const {formId, close} = useDialogContext();
|
||||
const generateRefreshToken = useGenerateDropboxRefreshToken();
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Connected dropbox account" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Form
|
||||
id={formId}
|
||||
form={form}
|
||||
onSubmit={data => {
|
||||
generateRefreshToken.mutate(
|
||||
{
|
||||
app_key: appKey,
|
||||
app_secret: appSecret,
|
||||
access_code: data.accessCode,
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
close(response.refreshToken);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="mb-20 pb-20 border-b">
|
||||
<div className="text-muted text-sm mb-10">
|
||||
<Trans message="Click the 'get access code' button to get dropbox access code, then paste it into the field below." />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
elementType="a"
|
||||
target="_blank"
|
||||
href={`https://www.dropbox.com/oauth2/authorize?client_id=${appKey}&token_access_type=offline&response_type=code`}
|
||||
>
|
||||
<Trans message="Get access code" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormTextField
|
||||
name="accessCode"
|
||||
label={<Trans message="Dropbox access code" />}
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
form={formId}
|
||||
type="submit"
|
||||
disabled={!appKey || !appSecret || generateRefreshToken.isPending}
|
||||
>
|
||||
<Trans message="Connect" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient} from '../../../../../http/query-client';
|
||||
import {BackendResponse} from '../../../../../http/backend-response/backend-response';
|
||||
import {showHttpErrorToast} from '../../../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
app_key: string;
|
||||
app_secret: string;
|
||||
access_code: string;
|
||||
}
|
||||
|
||||
export function useGenerateDropboxRefreshToken() {
|
||||
return useMutation({
|
||||
mutationFn: (props: Payload) => generateToken(props),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function generateToken(payload: Payload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('settings/uploading/dropbox-refresh-token', payload)
|
||||
.then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
export interface FetchMaxServerUploadSizeResponse extends BackendResponse {
|
||||
maxSize: string;
|
||||
}
|
||||
|
||||
function fetchMaxServerUploadSize(): Promise<FetchMaxServerUploadSizeResponse> {
|
||||
return apiClient
|
||||
.get('uploads/server-max-file-size')
|
||||
.then(response => response.data);
|
||||
}
|
||||
|
||||
export function useMaxServerUploadSize() {
|
||||
return useQuery({
|
||||
queryKey: ['MaxServerUploadSize'],
|
||||
queryFn: () => fetchMaxServerUploadSize(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {SettingsPanel} from '../../settings-panel';
|
||||
import {FormSelect, Option} from '../../../../ui/forms/select/select';
|
||||
import {AdminSettings} from '../../admin-settings';
|
||||
import {SettingsErrorGroup} from '../../settings-error-group';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {FormSwitch} from '@common/ui/forms/toggle/switch';
|
||||
import {FormRadioGroup} from '@common/ui/forms/radio-group/radio-group';
|
||||
import {FormRadio} from '@common/ui/forms/radio-group/radio';
|
||||
import {SectionHelper} from '@common/ui/section-helper';
|
||||
import {useMaxServerUploadSize} from './max-server-upload-size';
|
||||
import {SettingsSeparator} from '../../settings-separator';
|
||||
import {JsonChipField} from '../../json-chip-field';
|
||||
import {FormFileSizeField} from '@common/ui/forms/input-field/file-size-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Fragment} from 'react';
|
||||
import {useUploadS3Cors} from './use-upload-s3-cors';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {DropboxForm} from './dropbox-form/dropbox-form';
|
||||
import {useAdminSettings} from '../../requests/use-admin-settings';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
export function UploadingSettings() {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={<Trans message="Uploading" />}
|
||||
description={
|
||||
<Trans message="Configure size and type of files that users are able to upload. This will affect all uploads across the site." />
|
||||
}
|
||||
>
|
||||
<PrivateUploadSection />
|
||||
<PublicUploadSection />
|
||||
<CredentialsSection />
|
||||
<SettingsErrorGroup name="static_delivery_group">
|
||||
{isInvalid => (
|
||||
<FormRadioGroup
|
||||
invalid={isInvalid}
|
||||
size="sm"
|
||||
name="server.static_file_delivery"
|
||||
orientation="vertical"
|
||||
label={<Trans message="File delivery optimization" />}
|
||||
description={
|
||||
<Trans message="Both X-Sendfile and X-Accel need to be enabled on the server first. When enabled, it will reduce server memory and CPU usage when previewing or downloading files, especially for large files." />
|
||||
}
|
||||
>
|
||||
<FormRadio value="">
|
||||
<Trans message="None" />
|
||||
</FormRadio>
|
||||
<FormRadio value="xsendfile">
|
||||
<Trans message="X-Sendfile (Apache)" />
|
||||
</FormRadio>
|
||||
<FormRadio value="xaccel">
|
||||
<Trans message="X-Accel (Nginx)" />
|
||||
</FormRadio>
|
||||
</FormRadioGroup>
|
||||
)}
|
||||
</SettingsErrorGroup>
|
||||
<FormFileSizeField
|
||||
className="mb-30"
|
||||
name="client.uploads.chunk_size"
|
||||
min={1}
|
||||
label={<Trans message="Chunk size" />}
|
||||
placeholder="Infinity"
|
||||
description={
|
||||
<Trans message="Size (in bytes) for each file chunk. It should only be changed if there is a maximum upload size on your server or proxy (for example cloudflare). If chunk size is larger then limit on the server, uploads will fail." />
|
||||
}
|
||||
/>
|
||||
<MaxUploadSizeSection />
|
||||
<SettingsSeparator />
|
||||
<FormFileSizeField
|
||||
min={1}
|
||||
name="client.uploads.max_size"
|
||||
className="mb-30"
|
||||
label={<Trans message="Maximum file size" />}
|
||||
description={
|
||||
<Trans message="Maximum size (in bytes) for a single file user can upload." />
|
||||
}
|
||||
/>
|
||||
<FormFileSizeField
|
||||
min={1}
|
||||
name="client.uploads.available_space"
|
||||
className="mb-30"
|
||||
label={<Trans message="Available space" />}
|
||||
description={
|
||||
<Trans message="Disk space (in bytes) each user uploads are allowed to take up. This can be overridden per user." />
|
||||
}
|
||||
/>
|
||||
<JsonChipField
|
||||
name="client.uploads.allowed_extensions"
|
||||
className="mb-30"
|
||||
label={<Trans message="Allowed extensions" />}
|
||||
placeholder={trans(message('Add extension...'))}
|
||||
description={
|
||||
<Trans message="List of allowed file types (jpg, mp3, pdf etc.). Leave empty to allow all file types." />
|
||||
}
|
||||
/>
|
||||
<JsonChipField
|
||||
name="client.uploads.blocked_extensions"
|
||||
label={<Trans message="Blocked extensions" />}
|
||||
placeholder={trans(message('Add extension...'))}
|
||||
description={
|
||||
<Trans message="Prevent uploading of these file types, even if they are allowed above." />
|
||||
}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function MaxUploadSizeSection() {
|
||||
const {data} = useMaxServerUploadSize();
|
||||
return (
|
||||
<SectionHelper
|
||||
color="warning"
|
||||
description={
|
||||
<Trans
|
||||
message="Maximum upload size on your server currently is set to <b>:size</b>"
|
||||
values={{size: data?.maxSize, b: chunks => <b>{chunks}</b>}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateUploadSection() {
|
||||
const {watch, clearErrors} = useFormContext<AdminSettings>();
|
||||
const isEnabled = watch('server.uploads_disk_driver');
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
className="mb-30"
|
||||
selectionMode="single"
|
||||
name="server.uploads_disk_driver"
|
||||
label={<Trans message="User Uploads Storage Method" />}
|
||||
description={
|
||||
<Trans message="Where should user private file uploads be stored." />
|
||||
}
|
||||
onSelectionChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
>
|
||||
<Option value="local">
|
||||
<Trans message="Local Disk (Default)" />
|
||||
</Option>
|
||||
<Option value="ftp">FTP</Option>
|
||||
<Option value="digitalocean_s3">DigitalOcean Spaces</Option>
|
||||
<Option value="backblaze_s3">Backblaze</Option>
|
||||
<Option value="s3">Amazon S3 (Or compatible service)</Option>
|
||||
<Option value="dropbox">Dropbox</Option>
|
||||
<Option value="rackspace">Rackspace</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicUploadSection() {
|
||||
const {watch, clearErrors} = useFormContext<AdminSettings>();
|
||||
const isEnabled = watch('server.public_disk_driver');
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
label={<Trans message="Public Uploads Storage Method" />}
|
||||
selectionMode="single"
|
||||
name="server.public_disk_driver"
|
||||
description={
|
||||
<Trans message="Where should user public uploads (like avatars) be stored." />
|
||||
}
|
||||
onSelectionChange={() => {
|
||||
clearErrors();
|
||||
}}
|
||||
>
|
||||
<Option value="local">
|
||||
<Trans message="Local Disk (Default)" />
|
||||
</Option>
|
||||
<Option value="s3">Amazon S3</Option>
|
||||
<Option value="ftp">FTP</Option>
|
||||
<Option value="digitalocean_s3">DigitalOcean Spaces</Option>
|
||||
<Option value="backblaze_s3">Backblaze</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsSection() {
|
||||
const {watch} = useFormContext<AdminSettings>();
|
||||
const drives = [
|
||||
watch('server.uploads_disk_driver'),
|
||||
watch('server.public_disk_driver'),
|
||||
];
|
||||
|
||||
if (drives[0] === 'local' && drives[1] === 'local') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsErrorGroup separatorBottom={false} name="storage_group">
|
||||
{isInvalid => {
|
||||
if (drives.includes('s3')) {
|
||||
return <S3Form isInvalid={isInvalid} />;
|
||||
}
|
||||
if (drives.includes('ftp')) {
|
||||
return <FtpForm isInvalid={isInvalid} />;
|
||||
}
|
||||
if (drives.includes('dropbox')) {
|
||||
return <DropboxForm isInvalid={isInvalid} />;
|
||||
}
|
||||
if (drives.includes('digitalocean_s3')) {
|
||||
return <DigitalOceanForm isInvalid={isInvalid} />;
|
||||
}
|
||||
if (drives.includes('backblaze_s3')) {
|
||||
return <BackblazeForm isInvalid={isInvalid} />;
|
||||
}
|
||||
}}
|
||||
</SettingsErrorGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CredentialFormProps {
|
||||
isInvalid: boolean;
|
||||
}
|
||||
function S3Form({isInvalid}: CredentialFormProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_s3_key"
|
||||
label={<Trans message="Amazon S3 key" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_s3_secret"
|
||||
label={<Trans message="Amazon S3 secret" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_s3_region"
|
||||
label={<Trans message="Amazon S3 region" />}
|
||||
pattern="[a-z1-9\-]+"
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_s3_bucket"
|
||||
label={<Trans message="Amazon S3 bucket" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
name="server.storage_s3_endpoint"
|
||||
label={<Trans message="Amazon S3 endpoint" />}
|
||||
description={
|
||||
<Trans message="Only change endpoint if you are using another S3 compatible storage service." />
|
||||
}
|
||||
/>
|
||||
<S3DirectUploadField invalid={isInvalid} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function DigitalOceanForm({isInvalid}: CredentialFormProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_digitalocean_key"
|
||||
label={<Trans message="DigitalOcean key" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_digitalocean_secret"
|
||||
label={<Trans message="DigitalOcean secret" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_digitalocean_region"
|
||||
label={<Trans message="DigitalOcean region" />}
|
||||
pattern="[a-z0-9\-]+"
|
||||
placeholder="us-east-1"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_digitalocean_bucket"
|
||||
label={<Trans message="DigitalOcean bucket" />}
|
||||
required
|
||||
/>
|
||||
<S3DirectUploadField invalid={isInvalid} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function BackblazeForm({isInvalid}: CredentialFormProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_backblaze_key"
|
||||
label={<Trans message="Backblaze KeyID" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_backblaze_secret"
|
||||
label={<Trans message="Backblaze applicationKey" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_backblaze_region"
|
||||
label={<Trans message="Backblaze Region" />}
|
||||
pattern="[a-z0-9\-]+"
|
||||
placeholder="us-west-002"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_backblaze_bucket"
|
||||
label={<Trans message="Backblaze bucket name" />}
|
||||
required
|
||||
/>
|
||||
<S3DirectUploadField invalid={isInvalid} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface S3DirectUploadFieldProps {
|
||||
invalid: boolean;
|
||||
}
|
||||
function S3DirectUploadField({invalid}: S3DirectUploadFieldProps) {
|
||||
const uploadCors = useUploadS3Cors();
|
||||
const {data: defaultSettings} = useAdminSettings();
|
||||
|
||||
const s3DriverEnabled =
|
||||
defaultSettings?.server.uploads_disk_driver?.endsWith('s3') ||
|
||||
defaultSettings?.server.public_disk_driver?.endsWith('s3');
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FormSwitch
|
||||
className="mt-30"
|
||||
invalid={invalid}
|
||||
name="client.uploads.s3_direct_upload"
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
<Trans message="Upload files directly from the browser to s3 without going through the server. It will save on server bandwidth and should result in faster upload times. This should be enabled, unless storage provider does not support multipart uploads." />
|
||||
</p>
|
||||
<p className="mt-10">
|
||||
<Trans message="If s3 provider is not configured to allow uploads from browser, this can be done automatically via CORS button below, when valid credentials are saved." />
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Trans message="Direct upload" />
|
||||
</FormSwitch>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="xs"
|
||||
className="mt-20"
|
||||
onClick={() => {
|
||||
uploadCors.mutate();
|
||||
}}
|
||||
disabled={!s3DriverEnabled || uploadCors.isPending}
|
||||
>
|
||||
<Trans message="Configure CORS" />
|
||||
</Button>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function FtpForm({isInvalid}: CredentialFormProps) {
|
||||
return (
|
||||
<>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_ftp_host"
|
||||
label={<Trans message="FTP hostname" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_ftp_username"
|
||||
label={<Trans message="FTP username" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_ftp_password"
|
||||
label={<Trans message="FTP password" />}
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_ftp_root"
|
||||
label={<Trans message="FTP directory" />}
|
||||
placeholder="/"
|
||||
/>
|
||||
<FormTextField
|
||||
invalid={isInvalid}
|
||||
className="mb-30"
|
||||
name="server.storage_ftp_port"
|
||||
label={<Trans message="FTP port" />}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="21"
|
||||
/>
|
||||
<FormSwitch
|
||||
invalid={isInvalid}
|
||||
name="server.storage_ftp_passive"
|
||||
className="mb-30"
|
||||
>
|
||||
<Trans message="Passive" />
|
||||
</FormSwitch>
|
||||
<FormSwitch invalid={isInvalid} name="server.storage_ftp_ssl">
|
||||
<Trans message="SSL" />
|
||||
</FormSwitch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
import {useTrans} from '../../../../i18n/use-trans';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useUploadS3Cors() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: () => uploadCors(),
|
||||
onSuccess: () => {
|
||||
toast(trans(message('CORS file updated')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function uploadCors(): Promise<Response> {
|
||||
return apiClient.post('s3/cors/upload').then(r => r.data);
|
||||
}
|
||||
Reference in New Issue
Block a user