mirror of
https://github.com/nextcloud/server.git
synced 2025-02-22 07:59:13 +00:00
First pass at ai admin settings
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
This commit is contained in:
parent
78856f354f
commit
fc9780a41d
11 changed files with 459 additions and 0 deletions
apps/settings
appinfo
img
lib
Controller
Sections/Admin
Settings/Admin
src/components
templates/settings/admin
lib/public
webpack.modules.js
|
@ -19,6 +19,7 @@
|
|||
<settings>
|
||||
<admin>OCA\Settings\Settings\Admin\Mail</admin>
|
||||
<admin>OCA\Settings\Settings\Admin\Overview</admin>
|
||||
<admin>OCA\Settings\Settings\Admin\ArtificialIntelligence</admin>
|
||||
<admin>OCA\Settings\Settings\Admin\Server</admin>
|
||||
<admin>OCA\Settings\Settings\Admin\Sharing</admin>
|
||||
<admin>OCA\Settings\Settings\Admin\Security</admin>
|
||||
|
@ -27,6 +28,7 @@
|
|||
<admin-section>OCA\Settings\Sections\Admin\Delegation</admin-section>
|
||||
<admin-section>OCA\Settings\Sections\Admin\Groupware</admin-section>
|
||||
<admin-section>OCA\Settings\Sections\Admin\Overview</admin-section>
|
||||
<admin-section>OCA\Settings\Sections\Admin\ArtificialIntelligence</admin-section>
|
||||
<admin-section>OCA\Settings\Sections\Admin\Security</admin-section>
|
||||
<admin-section>OCA\Settings\Sections\Admin\Server</admin-section>
|
||||
<admin-section>OCA\Settings\Sections\Admin\Sharing</admin-section>
|
||||
|
|
1
apps/settings/img/ai.svg
Normal file
1
apps/settings/img/ai.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z" /></svg>
|
After (image error) Size: 513 B |
105
apps/settings/lib/Controller/AISettingsController.php
Normal file
105
apps/settings/lib/Controller/AISettingsController.php
Normal file
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2017 Joas Schilling <coding@schilljs.com>
|
||||
* @copyright Copyright (c) 2016, ownCloud, Inc.
|
||||
*
|
||||
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
* @author Daniel Kesselberg <mail@danielkesselberg.de>
|
||||
* @author Joas Schilling <coding@schilljs.com>
|
||||
* @author Lukas Reschke <lukas@statuscode.ch>
|
||||
* @author Morris Jobke <hey@morrisjobke.de>
|
||||
* @author Roeland Jago Douma <roeland@famdouma.nl>
|
||||
*
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
namespace OCA\Settings\Controller;
|
||||
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Mail\IMailer;
|
||||
|
||||
class AISettingsController extends Controller {
|
||||
|
||||
/** @var IL10N */
|
||||
private $l10n;
|
||||
/** @var IConfig */
|
||||
private $config;
|
||||
/** @var IUserSession */
|
||||
private $userSession;
|
||||
/** @var IMailer */
|
||||
private $mailer;
|
||||
/** @var IURLGenerator */
|
||||
private $urlGenerator;
|
||||
|
||||
/**
|
||||
* @param string $appName
|
||||
* @param IRequest $request
|
||||
* @param IL10N $l10n
|
||||
* @param IConfig $config
|
||||
* @param IUserSession $userSession
|
||||
* @param IURLGenerator $urlGenerator,
|
||||
* @param IMailer $mailer
|
||||
*/
|
||||
public function __construct($appName,
|
||||
IRequest $request,
|
||||
IL10N $l10n,
|
||||
IConfig $config,
|
||||
IUserSession $userSession,
|
||||
IURLGenerator $urlGenerator,
|
||||
IMailer $mailer) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->l10n = $l10n;
|
||||
$this->config = $config;
|
||||
$this->userSession = $userSession;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->mailer = $mailer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the email settings
|
||||
*
|
||||
* @PasswordConfirmationRequired
|
||||
* @AuthorizedAdminSetting(settings=OCA\Settings\Settings\Admin\ArtificialIntelligence)
|
||||
*
|
||||
* @param array $settings
|
||||
* @return DataResponse
|
||||
*/
|
||||
public function setAISettings($settings) {
|
||||
$params = get_defined_vars();
|
||||
$configs = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$configs[$key] = empty($value) ? null : $value;
|
||||
}
|
||||
|
||||
// Delete passwords from config in case no auth is specified
|
||||
if ($params['mail_smtpauth'] !== 1) {
|
||||
$configs['mail_smtpname'] = null;
|
||||
$configs['mail_smtppassword'] = null;
|
||||
}
|
||||
|
||||
$this->config->setSystemValues($configs);
|
||||
|
||||
$this->config->setAppValue('core', 'emailTestSuccessful', '0');
|
||||
|
||||
return new DataResponse();
|
||||
}
|
||||
}
|
58
apps/settings/lib/Sections/Admin/ArtificialIntelligence.php
Normal file
58
apps/settings/lib/Sections/Admin/ArtificialIntelligence.php
Normal file
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
namespace OCA\Settings\Sections\Admin;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
class ArtificialIntelligence implements IIconSection {
|
||||
|
||||
/** @var IL10N */
|
||||
private $l;
|
||||
|
||||
/** @var IURLGenerator */
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
|
||||
$this->l = $l;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath('settings', 'ai.svg');
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l->t('Artificial Intelligence');
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 40;
|
||||
}
|
||||
}
|
163
apps/settings/lib/Settings/Admin/ArtificialIntelligence.php
Normal file
163
apps/settings/lib/Settings/Admin/ArtificialIntelligence.php
Normal file
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net>
|
||||
*
|
||||
* @author Marcel Klehr <mklehr@gmx.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
namespace OCA\Settings\Settings\Admin;
|
||||
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IServerContainer;
|
||||
use OCP\Settings\IDelegatedSettings;
|
||||
use OCP\SpeechToText\ISpeechToTextManager;
|
||||
use OCP\TextProcessing\IManager;
|
||||
use OCP\TextProcessing\IProvider;
|
||||
use OCP\TextProcessing\ITaskType;
|
||||
use OCP\Translation\ITranslationManager;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
class ArtificialIntelligence implements IDelegatedSettings {
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private IL10N $l,
|
||||
private IInitialState $initialState,
|
||||
private ITranslationManager $translationManager,
|
||||
private ISpeechToTextManager $sttManager,
|
||||
private IManager $textProcessingManager,
|
||||
private IServerContainer $container,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm() {
|
||||
$translationProviders = [];
|
||||
$translationPreferences = [];
|
||||
foreach ($this->translationManager->getProviders() as $provider) {
|
||||
$translationProviders[] = [
|
||||
'class' => $provider::class,
|
||||
'name' => $provider->getName(),
|
||||
];
|
||||
$translationPreferences[] = $provider::class;
|
||||
}
|
||||
|
||||
$sttProviders = [];
|
||||
foreach ($this->sttManager->getProviders() as $provider) {
|
||||
$sttProviders[] = [
|
||||
'class' => $provider::class,
|
||||
'name' => $provider->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$textProcessingProviders = [];
|
||||
/** @var array<class-string<ITaskType>, class-string<IProvider>> $textProcessingSettings */
|
||||
$textProcessingSettings = [];
|
||||
foreach ($this->textProcessingManager->getProviders() as $provider) {
|
||||
$textProcessingProviders[] = [
|
||||
'class' => $provider::class,
|
||||
'name' => $provider->getName(),
|
||||
'taskType' => $provider->getTaskType(),
|
||||
];
|
||||
$textProcessingSettings[$provider->getTaskType()] = $provider::class;
|
||||
}
|
||||
$textProcessingTaskTypes = [];
|
||||
foreach ($textProcessingSettings as $taskTypeClass => $providerClass) {
|
||||
/** @var ITaskType $taskType */
|
||||
try {
|
||||
$taskType = $this->container->get($taskTypeClass);
|
||||
} catch (NotFoundExceptionInterface $e) {
|
||||
continue;
|
||||
} catch (ContainerExceptionInterface $e) {
|
||||
continue;
|
||||
}
|
||||
$textProcessingTaskTypes[] = [
|
||||
'class' => $taskTypeClass,
|
||||
'name' => $taskType->getName(),
|
||||
'description' => $taskType->getDescription(),
|
||||
];
|
||||
}
|
||||
|
||||
$this->initialState->provideInitialState('ai-stt-providers', $sttProviders);
|
||||
$this->initialState->provideInitialState('ai-translation-providers', $translationProviders);
|
||||
$this->initialState->provideInitialState('ai-text-processing-providers', $textProcessingProviders);
|
||||
$this->initialState->provideInitialState('ai-text-processing-task-types', $textProcessingTaskTypes);
|
||||
|
||||
$settings = [
|
||||
'ai.stt_provider' => count($sttProviders) > 0 ? $sttProviders[0]['class'] : null,
|
||||
'ai.textprocessing_provider_preferences' => $textProcessingSettings,
|
||||
'ai.translation_provider_preferences' => $translationPreferences,
|
||||
];
|
||||
foreach ($settings as $key => $defaultValue) {
|
||||
$value = $defaultValue;
|
||||
$json = $this->config->getAppValue('core', $key, '');
|
||||
if ($json !== '') {
|
||||
$value = json_decode($json, JSON_OBJECT_AS_ARRAY);
|
||||
switch($key) {
|
||||
case 'ai.textprocessing_provider_preferences':
|
||||
// fill $value with $defaultValue values
|
||||
$value = array_merge($defaultValue, $value);
|
||||
break;
|
||||
case 'ai.translation_provider_preferences':
|
||||
$value += array_diff($defaultValue, $value); // Add entries from $defaultValue that are not in $value to the end of $value
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
$settings[$key] = $value;
|
||||
}
|
||||
|
||||
$this->initialState->provideInitialState('ai-settings', $settings);
|
||||
|
||||
return new TemplateResponse('settings', 'settings/admin/ai');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string the section ID, e.g. 'sharing'
|
||||
*/
|
||||
public function getSection() {
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int whether the form should be rather on the top or bottom of
|
||||
* the admin section. The forms are arranged in ascending order of the
|
||||
* priority values. It is required to return a value between 0 and 100.
|
||||
*
|
||||
* E.g.: 70
|
||||
*/
|
||||
public function getPriority() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
public function getName(): ?string {
|
||||
return $this->l->t('Artificial Intelligence');
|
||||
}
|
||||
|
||||
public function getAuthorizedAppConfig(): array {
|
||||
return [
|
||||
'core' => ['/ai_.*/'],
|
||||
];
|
||||
}
|
||||
}
|
83
apps/settings/src/components/AdminAI.vue
Normal file
83
apps/settings/src/components/AdminAI.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<NcSettingsSection :title="t('settings', 'Artificial Intelligence')"
|
||||
:description="t('settings', 'Artificial Intelligence features can be implemented by different apps. Here you can set which app should be used for which features.')">
|
||||
<h3>{{ t('settings', 'Translations') }}</h3>
|
||||
<h3>{{ t('settings', 'Speech-To-Text') }}</h3>
|
||||
<template v-for="provider in sttProviders">
|
||||
<NcCheckboxRadioSwitch :key="provider.class"
|
||||
:checked.sync="settings['ai.stt_provider']"
|
||||
:value="provider.class"
|
||||
name="stt_provider"
|
||||
type="radio">{{ provider.name }}</NcCheckboxRadioSwitch>
|
||||
</template>
|
||||
<template v-if="sttProviders.length === 0">
|
||||
<NcCheckboxRadioSwitch disabled type="radio">{{ t('settings', 'No apps are currently installed that provide Speech-To-Text functionality') }}</NcCheckboxRadioSwitch>
|
||||
</template>
|
||||
<h3>{{ t('settings', 'Text processing') }}</h3>
|
||||
<template v-for="(type, provider) in settings['ai.textprocessing_provider_preferences']">
|
||||
<h4>{{ type }}</h4>
|
||||
<!--<p>{{ getTaskType(type).description }}</p>
|
||||
<NcSelect v-model="settings['ai.textprocessing_provider_preferences'][type]" :options="textProcessingProviders.filter(provider => provider.taskType === type)" />-->
|
||||
</template>
|
||||
<template v-if="Object.keys(settings['ai.textprocessing_provider_preferences']).length === 0 || !Array.isArray(this.textProcessingTaskTypes)">
|
||||
<p>{{ t('settings', 'No apps are currently installed that provide Text processing functionality') }}</p>
|
||||
</template>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from '@nextcloud/axios'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
export default {
|
||||
name: 'AdminAI',
|
||||
components: {
|
||||
NcCheckboxRadioSwitch,
|
||||
NcSettingsSection,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
dirty: false,
|
||||
groups: [],
|
||||
loadingGroups: false,
|
||||
sttProviders: loadState('settings', 'ai-stt-providers'),
|
||||
translationProviders: loadState('settings', 'ai-translation-providers'),
|
||||
textProcessingProviders: loadState('settings', 'ai-text-processing-providers'),
|
||||
textProcessingTaskTypes: loadState('settings', 'ai-text-processing-task-types'),
|
||||
settings: loadState('settings', 'ai-settings'),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveChanges() {
|
||||
this.loading = true
|
||||
|
||||
const data = {
|
||||
enforced: this.enforced,
|
||||
enforcedGroups: this.enforcedGroups,
|
||||
excludedGroups: this.excludedGroups,
|
||||
}
|
||||
axios.put(generateUrl('/settings/api/admin/twofactorauth'), data)
|
||||
.then(resp => resp.data)
|
||||
.then(state => {
|
||||
this.state = state
|
||||
this.dirty = false
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('could not save changes', err)
|
||||
})
|
||||
.then(() => { this.loading = false })
|
||||
},
|
||||
getTaskType(type) {
|
||||
if (!Array.isArray(this.textProcessingTaskTypes)) {
|
||||
return null
|
||||
}
|
||||
return this.textProcessingTaskTypes.find(taskType => taskType === type)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
28
apps/settings/templates/settings/admin/ai.php
Normal file
28
apps/settings/templates/settings/admin/ai.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
script('settings', [
|
||||
'vue-settings-admin-ai',
|
||||
]);
|
||||
?>
|
||||
|
||||
<div id="ai-settings">
|
||||
</div>
|
|
@ -40,6 +40,12 @@ interface ISpeechToTextManager {
|
|||
*/
|
||||
public function hasProviders(): bool;
|
||||
|
||||
/**
|
||||
* @return ISpeechToTextProvider[]
|
||||
* @since 27.1.0
|
||||
*/
|
||||
public function getProviders(): array;
|
||||
|
||||
/**
|
||||
* Will schedule a transcription process in the background. The result will become available
|
||||
* with the \OCP\SpeechToText\Events\TranscriptionFinishedEvent
|
||||
|
|
|
@ -41,6 +41,12 @@ interface IManager {
|
|||
*/
|
||||
public function hasProviders(): bool;
|
||||
|
||||
/**
|
||||
* @return IProvider[]
|
||||
* @since 27.1.0
|
||||
*/
|
||||
public function getProviders(): array;
|
||||
|
||||
/**
|
||||
* @return class-string<ITaskType>[]
|
||||
* @since 27.1.0
|
||||
|
|
|
@ -38,6 +38,12 @@ interface ITranslationManager {
|
|||
*/
|
||||
public function hasProviders(): bool;
|
||||
|
||||
/**
|
||||
* @return ITranslationProvider[]
|
||||
* @since 27.1.0
|
||||
*/
|
||||
public function getProviders(): array;
|
||||
|
||||
/**
|
||||
* @since 26.0.0
|
||||
*/
|
||||
|
|
|
@ -82,6 +82,7 @@ module.exports = {
|
|||
apps: path.join(__dirname, 'apps/settings/src', 'apps.js'),
|
||||
'legacy-admin': path.join(__dirname, 'apps/settings/src', 'admin.js'),
|
||||
'vue-settings-admin-basic-settings': path.join(__dirname, 'apps/settings/src', 'main-admin-basic-settings.js'),
|
||||
'vue-settings-admin-ai': path.join(__dirname, 'apps/settings/src', 'main-admin-ai.js'),
|
||||
'vue-settings-admin-delegation': path.join(__dirname, 'apps/settings/src', 'main-admin-delegation.js'),
|
||||
'vue-settings-admin-security': path.join(__dirname, 'apps/settings/src', 'main-admin-security.js'),
|
||||
'vue-settings-apps-users-management': path.join(__dirname, 'apps/settings/src', 'main-apps-users-management.js'),
|
||||
|
|
Loading…
Reference in a new issue