diff --git a/changelog.d/adminfe.add b/changelog.d/adminfe.add new file mode 100644 index 00000000..188c4555 --- /dev/null +++ b/changelog.d/adminfe.add @@ -0,0 +1 @@ +Implemented a very basic instance administration screen diff --git a/src/App.scss b/src/App.scss index 3f352e8d..ef68ac50 100644 --- a/src/App.scss +++ b/src/App.scss @@ -645,6 +645,20 @@ option { } } +.cards-list { + list-style: none; + display: grid; + grid-auto-flow: row dense; + grid-template-columns: 1fr 1fr; + + li { + border: 1px solid var(--border); + border-radius: var(--inputRadius); + padding: 0.5em; + margin: 0.25em; + } +} + .btn-block { display: block; width: 100%; @@ -655,16 +669,19 @@ option { display: inline-flex; vertical-align: middle; - button { + button, + .button-dropdown { position: relative; flex: 1 1 auto; - &:not(:last-child) { + &:not(:last-child), + &:not(:last-child) .button-default { border-top-right-radius: 0; border-bottom-right-radius: 0; } - &:not(:first-child) { + &:not(:first-child), + &:not(:first-child) .button-default { border-top-left-radius: 0; border-bottom-left-radius: 0; } diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index 42f89be9..b8b77e7c 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -1,7 +1,7 @@ diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js index 3da559fe..bdeece76 100644 --- a/src/components/settings_modal/helpers/choice_setting.js +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -1,51 +1,41 @@ -import { get, set } from 'lodash' import Select from 'src/components/select/select.vue' -import ModifiedIndicator from './modified_indicator.vue' -import ServerSideIndicator from './server_side_indicator.vue' +import Setting from './setting.js' + export default { + ...Setting, components: { - Select, - ModifiedIndicator, - ServerSideIndicator + ...Setting.components, + Select + }, + props: { + ...Setting.props, + options: { + type: Array, + required: false + }, + optionLabelMap: { + type: Object, + required: false, + default: {} + } }, - props: [ - 'path', - 'disabled', - 'options', - 'expert' - ], computed: { - pathDefault () { - const [firstSegment, ...rest] = this.path.split('.') - return [firstSegment + 'DefaultValue', ...rest].join('.') - }, - state () { - const value = get(this.$parent, this.path) - if (value === undefined) { - return this.defaultState - } else { - return value + ...Setting.computed, + realOptions () { + if (this.realSource === 'admin') { + return this.backendDescriptionSuggestions.map(x => ({ + key: x, + value: x, + label: this.optionLabelMap[x] || x + })) } - }, - defaultState () { - return get(this.$parent, this.pathDefault) - }, - isServerSide () { - return this.path.startsWith('serverSide_') - }, - isChanged () { - return !this.path.startsWith('serverSide_') && this.state !== this.defaultState - }, - matchesExpertLevel () { - return (this.expert || 0) <= this.$parent.expertLevel + return this.options } }, methods: { - update (e) { - set(this.$parent, this.path, e) - }, - reset () { - set(this.$parent, this.path, this.defaultState) + ...Setting.methods, + getValue (e) { + return e } } } diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index 8fdbb5d3..114e9b7d 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -3,15 +3,20 @@ v-if="matchesExpertLevel" class="ChoiceSetting" > - + + {{ ' ' }} {{ ' ' }} @@ -21,6 +30,15 @@ :changed="isChanged" :onclick="reset" /> + + +

+ {{ backendDescriptionDescription + ' ' }} +

diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/profile_setting_indicator.vue similarity index 81% rename from src/components/settings_modal/helpers/server_side_indicator.vue rename to src/components/settings_modal/helpers/profile_setting_indicator.vue index bf181959..d160781b 100644 --- a/src/components/settings_modal/helpers/server_side_indicator.vue +++ b/src/components/settings_modal/helpers/profile_setting_indicator.vue @@ -1,7 +1,7 @@ @@ -33,17 +33,17 @@ library.add( export default { components: { Popover }, - props: ['serverSide'] + props: ['isProfile'] } diff --git a/src/components/settings_modal/helpers/string_setting.js b/src/components/settings_modal/helpers/string_setting.js new file mode 100644 index 00000000..b368cfc8 --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.js @@ -0,0 +1,5 @@ +import Setting from './setting.js' + +export default { + ...Setting +} diff --git a/src/components/settings_modal/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue new file mode 100644 index 00000000..0cfa61ce --- /dev/null +++ b/src/components/settings_modal/helpers/string_setting.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index 0a72dca1..ff58f2c3 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -5,7 +5,7 @@ import getResettableAsyncComponent from 'src/services/resettable_async_component import Popover from '../popover/popover.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' -import { cloneDeep } from 'lodash' +import { cloneDeep, isEqual } from 'lodash' import { newImporter, newExporter @@ -53,8 +53,16 @@ const SettingsModal = { Modal, Popover, Checkbox, - SettingsModalContent: getResettableAsyncComponent( - () => import('./settings_modal_content.vue'), + SettingsModalUserContent: getResettableAsyncComponent( + () => import('./settings_modal_user_content.vue'), + { + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, + delay: 0 + } + ), + SettingsModalAdminContent: getResettableAsyncComponent( + () => import('./settings_modal_admin_content.vue'), { loadingComponent: PanelLoading, errorComponent: AsyncComponentError, @@ -147,6 +155,12 @@ const SettingsModal = { PLEROMAFE_SETTINGS_MINOR_VERSION ] return clone + }, + resetAdminDraft () { + this.$store.commit('resetAdminDraft') + }, + pushAdminDraft () { + this.$store.dispatch('pushAdminDraft') } }, computed: { @@ -156,8 +170,14 @@ const SettingsModal = { modalActivated () { return this.$store.state.interface.settingsModalState !== 'hidden' }, - modalOpenedOnce () { - return this.$store.state.interface.settingsModalLoaded + modalMode () { + return this.$store.state.interface.settingsModalMode + }, + modalOpenedOnceUser () { + return this.$store.state.interface.settingsModalLoadedUser + }, + modalOpenedOnceAdmin () { + return this.$store.state.interface.settingsModalLoadedAdmin }, modalPeeked () { return this.$store.state.interface.settingsModalState === 'minimized' @@ -167,9 +187,14 @@ const SettingsModal = { return this.$store.state.config.expertLevel > 0 }, set (value) { - console.log(value) this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 }) } + }, + adminDraftAny () { + return !isEqual( + this.$store.state.adminSettings.config, + this.$store.state.adminSettings.draft + ) } } } diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index f5861229..49ef83e0 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -17,6 +17,12 @@ } } + .setting-description { + margin-top: 0.2em; + margin-bottom: 2em; + font-size: 70%; + } + .settings-modal-panel { overflow: hidden; transition: transform; @@ -37,7 +43,9 @@ .btn { min-height: 2em; - min-width: 10em; + } + + .btn:not(.dropdown-button) { padding: 0 2em; } } @@ -45,6 +53,8 @@ .settings-footer { display: flex; + flex-wrap: wrap; + line-height: 2; >* { margin-right: 0.5em; diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue index 7b457371..4e7fd931 100644 --- a/src/components/settings_modal/settings_modal.vue +++ b/src/components/settings_modal/settings_modal.vue @@ -8,7 +8,7 @@
- {{ $t('settings.settings') }} + {{ modalMode === 'user' ? $t('settings.settings') : $t('admin_dash.window_title') }}
- + +
- diff --git a/src/components/settings_modal/settings_modal_admin_content.js b/src/components/settings_modal/settings_modal_admin_content.js new file mode 100644 index 00000000..f94721ec --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.js @@ -0,0 +1,93 @@ +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' + +import InstanceTab from './admin_tabs/instance_tab.vue' +import LimitsTab from './admin_tabs/limits_tab.vue' +import FrontendsTab from './admin_tabs/frontends_tab.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench, + faHand, + faLaptopCode, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +) + +const SettingsModalAdminContent = { + components: { + TabSwitcher, + + InstanceTab, + LimitsTab, + FrontendsTab + }, + computed: { + user () { + return this.$store.state.users.currentUser + }, + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + open () { + return this.$store.state.interface.settingsModalState !== 'hidden' + }, + bodyLock () { + return this.$store.state.interface.settingsModalState === 'visible' + }, + adminDbLoaded () { + return this.$store.state.adminSettings.loaded + }, + adminDescriptionsLoaded () { + return this.$store.state.adminSettings.descriptions !== null + }, + noDb () { + return this.$store.state.adminSettings.dbConfigEnabled === false + } + }, + created () { + if (this.user.rights.admin) { + this.$store.dispatch('loadAdminStuff') + } + }, + methods: { + onOpen () { + const targetTab = this.$store.state.interface.settingsModalTargetTab + // We're being told to open in specific tab + if (targetTab) { + const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => { + return elm.props && elm.props['data-tab-name'] === targetTab + }) + if (tabIndex >= 0) { + this.$refs.tabSwitcher.setTab(tabIndex) + } + } + // Clear the state of target tab, so that next time settings is opened + // it doesn't force it. + this.$store.dispatch('clearSettingsModalTargetTab') + } + }, + mounted () { + this.onOpen() + }, + watch: { + open: function (value) { + if (value) this.onOpen() + } + } +} + +export default SettingsModalAdminContent diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss similarity index 94% rename from src/components/settings_modal/settings_modal_content.scss rename to src/components/settings_modal/settings_modal_admin_content.scss index 87df7982..c984d703 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_admin_content.scss @@ -48,9 +48,5 @@ color: var(--cRed, $fallback--cRed); color: $fallback--cRed; } - - .number-input { - max-width: 6em; - } } } diff --git a/src/components/settings_modal/settings_modal_admin_content.vue b/src/components/settings_modal/settings_modal_admin_content.vue new file mode 100644 index 00000000..a7a2ac9a --- /dev/null +++ b/src/components/settings_modal/settings_modal_admin_content.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_user_content.js similarity index 100% rename from src/components/settings_modal/settings_modal_content.js rename to src/components/settings_modal/settings_modal_user_content.js diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss new file mode 100644 index 00000000..c984d703 --- /dev/null +++ b/src/components/settings_modal/settings_modal_user_content.scss @@ -0,0 +1,52 @@ +@import "src/variables"; + +.settings_tab-switcher { + height: 100%; + + .setting-item { + border-bottom: 2px solid var(--fg, $fallback--fg); + margin: 1em 1em 1.4em; + padding-bottom: 1.4em; + + > div, + > label { + display: block; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } + + .select-multiple { + display: flex; + + .option-list { + margin: 0; + padding-left: 0.5em; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + + textarea { + width: 100%; + max-width: 100%; + height: 100px; + } + + .unavailable, + .unavailable svg { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + } +} diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_user_content.vue similarity index 92% rename from src/components/settings_modal/settings_modal_content.vue rename to src/components/settings_modal/settings_modal_user_content.vue index 0be76d22..0221cccb 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_user_content.vue @@ -78,6 +78,6 @@ - + - + diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 97046ff0..41d1b54f 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -7,13 +7,11 @@ {{ $t('settings.hide_filtered_statuses') }} -
    +
    • {{ $t('settings.hide_wordfiltered_statuses') }} @@ -22,7 +20,8 @@
    • {{ $t('settings.hide_muted_threads') }} @@ -31,7 +30,8 @@
    • {{ $t('settings.hide_muted_posts') }} diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index be97710f..3f2bcb13 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -7,7 +7,7 @@ import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' -import ServerSideIndicator from '../helpers/server_side_indicator.vue' +import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faGlobe @@ -67,7 +67,7 @@ const GeneralTab = { SizeSetting, InterfaceLanguageSwitcher, ScopeSelector, - ServerSideIndicator + ProfileSettingIndicator }, computed: { horizontalUnits () { @@ -110,7 +110,7 @@ const GeneralTab = { }, methods: { changeDefaultScope (value) { - this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value }) + this.$store.dispatch('setProfileOption', { name: 'defaultScope', value }) } } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 21e2d855..f56fa8e0 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -29,14 +29,11 @@ {{ $t('settings.streaming') }} -
        +
        • {{ $t('settings.pause_on_unfocused') }} @@ -213,7 +210,7 @@
          • @@ -265,7 +262,8 @@
          • {{ $t('settings.no_rich_text_description') }} @@ -299,7 +297,7 @@ {{ $t('settings.preload_images') }} @@ -308,7 +306,7 @@ {{ $t('settings.use_one_click_nsfw') }} @@ -321,15 +319,13 @@ > {{ $t('settings.loop_video') }} -
              +
              • {{ $t('settings.loop_video_silent_only') }} @@ -427,18 +423,18 @@
                • - + {{ $t('settings.sensitive_by_default') }} diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index dd3806ed..fcb92135 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -4,7 +4,10 @@

                  {{ $t('settings.notification_setting_filters') }}

                  • - + {{ $t('settings.notification_setting_block_from_strangers') }}
                  • @@ -67,7 +70,8 @@
                  • {{ $t('settings.notification_setting_hide_notification_contents') }} diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index 6a5b478a..1cc850cb 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -254,37 +254,50 @@

                    {{ $t('settings.account_privacy') }}

                    • - + {{ $t('settings.lock_account_description') }}
                    • - + {{ $t('settings.discoverable') }}
                    • - + {{ $t('settings.allow_following_move') }}
                    • - + {{ $t('settings.hide_favorites_description') }}
                    • - + {{ $t('settings.hide_followers_description') }} -
                        +
                        • {{ $t('settings.hide_followers_count_description') }} @@ -292,17 +305,18 @@
                      • - + {{ $t('settings.hide_follows_description') }} -
                          +
                          • {{ $t('settings.hide_follows_count_description') }} diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index 6e03bef4..d36d478f 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -143,8 +143,8 @@ />
- foo@example.org - + @@ -175,16 +175,16 @@

{{ $t('settings.move_account') }}

{{ $t('settings.move_account_notes') }}

- - - foo@example.org - - + + diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 27019577..81c5a612 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -115,7 +115,10 @@ const SideDrawer = { GestureService.updateSwipe(e, this.closeGesture) }, openSettingsModal () { - this.$store.dispatch('openSettingsModal') + this.$store.dispatch('openSettingsModal', 'user') + }, + openAdminModal () { + this.$store.dispatch('openSettingsModal', 'admin') } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 994ac953..09588767 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -180,16 +180,16 @@ v-if="currentUser && currentUser.role === 'admin'" @click="toggleDrawer" > - {{ $t("nav.administration") }} - +
  • slot.props && slot.props['data-tab-name'] === tabName return this.$slots.default().findIndex(isWanted) === this.activeIndex } - }, - settingsModalVisible () { - return this.settingsModalState === 'visible' - }, - ...mapState({ - settingsModalState: state => state.interface.settingsModalState - }) + } }, beforeUpdate () { const currentSlot = this.slots()[this.active] diff --git a/src/i18n/en.json b/src/i18n/en.json index 01309d0e..a7ab451f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -519,6 +519,8 @@ "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "mutes_tab": "Mutes", "play_videos_in_modal": "Play videos in a popup frame", + "url": "URL", + "preview": "Preview", "file_export_import": { "backup_restore": "Settings backup", "backup_settings": "Backup settings to file", @@ -830,6 +832,98 @@ "title": "Version", "backend_version": "Backend version", "frontend_version": "Frontend version" + }, + "commit_value": "Save", + "commit_value_tooltip": "Value is not saved, press this button to commit your changes", + "reset_value": "Reset", + "reset_value_tooltip": "Reset draft", + "hard_reset_value": "Hard reset", + "hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value" + }, + "admin_dash": { + "window_title": "Administration", + "wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.", + "old_ui_link": "old admin UI available here", + "reset_all": "Reset all", + "commit_all": "Save all", + "tabs": { + "nodb": "No DB Config", + "instance": "Instance", + "limits": "Limits", + "frontends": "Front-ends" + }, + "nodb": { + "heading": "Database config is disabled", + "text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.", + "documentation": "documentation", + "text2": "Most configuration options will be unavailable." + }, + "captcha": { + "native": "Native", + "kocaptcha": "KoCaptcha" + }, + "instance": { + "instance": "Instance information", + "registrations": "User sign-ups", + "captcha_header": "CAPTCHA", + "kocaptcha": "KoCaptcha settings", + "access": "Instance access", + "restrict": { + "header": "Restrict access for anonymous visitors", + "description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.", + "timelines": "Timelines access", + "profiles": "User profiles access", + "activities": "Statues/activities access" + } + }, + "limits": { + "arbitrary_limits": "Arbitrary limits", + "posts": "Post limits", + "uploads": "Attachments limits", + "users": "User profile limits", + "profile_fields": "Profile fields limits", + "user_uploads": "Profile media limits" + }, + "frontend": { + "repository": "Repository link", + "versions": "Available versions", + "build_url": "Build URL", + "reinstall": "Reinstall", + "is_default": "(Default)", + "is_default_custom": "(Default, version: {version})", + "install": "Install", + "install_version": "Install version {version}", + "more_install_options": "More install options", + "more_default_options": "More default setting options", + "set_default": "Set default", + "set_default_version": "Set version {version} as default", + "wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.", + "default_frontend": "Default front-end", + "default_frontend_tip": "Default front-end will be shown to all users. Currently there's no way to for a user to select personal front-end. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.", + "default_frontend_tip2": "WIP: Since Pleroma backend doesn't properly list all installed frontends you'll have to enter name and reference manually. List below provides shortcuts to fill the values.", + "available_frontends": "Available for install" + }, + "temp_overrides": { + ":pleroma": { + ":instance": { + ":public": { + "label": "Instance is public", + "description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors." + }, + ":limit_to_local_content": { + "label": "Limit search to local content", + "description": "Disables global network search for unauthenticated (default), all users or none" + }, + ":description_limit": { + "label": "Limit", + "description": "Character limit for attachment descriptions" + }, + ":background_image": { + "label": "Background image", + "description": "Background image (primarily used by PleromaFE)" + } + } + } } }, "time": { diff --git a/src/main.js b/src/main.js index 9ca7eb91..0b7c7674 100644 --- a/src/main.js +++ b/src/main.js @@ -10,8 +10,9 @@ import listsModule from './modules/lists.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' -import serverSideConfigModule from './modules/serverSideConfig.js' +import profileConfigModule from './modules/profileConfig.js' import serverSideStorageModule from './modules/serverSideStorage.js' +import adminSettingsModule from './modules/adminSettings.js' import shoutModule from './modules/shout.js' import oauthModule from './modules/oauth.js' import authFlowModule from './modules/auth_flow.js' @@ -80,8 +81,9 @@ const persistedStateOptions = { lists: listsModule, api: apiModule, config: configModule, - serverSideConfig: serverSideConfigModule, + profileConfig: profileConfigModule, serverSideStorage: serverSideStorageModule, + adminSettings: adminSettingsModule, shout: shoutModule, oauth: oauthModule, authFlow: authFlowModule, diff --git a/src/modules/adminSettings.js b/src/modules/adminSettings.js new file mode 100644 index 00000000..cad9c0ca --- /dev/null +++ b/src/modules/adminSettings.js @@ -0,0 +1,230 @@ +import { set, get, cloneDeep, differenceWith, isEqual, flatten } from 'lodash' + +export const defaultState = { + frontends: [], + loaded: false, + needsReboot: null, + config: null, + modifiedPaths: null, + descriptions: null, + draft: null, + dbConfigEnabled: null +} + +export const newUserFlags = { + ...defaultState.flagStorage +} + +const adminSettingsStorage = { + state: { + ...cloneDeep(defaultState) + }, + mutations: { + setInstanceAdminNoDbConfig (state) { + state.loaded = false + state.dbConfigEnabled = false + }, + setAvailableFrontends (state, { frontends }) { + state.frontends = frontends.map(f => { + if (f.name === 'pleroma-fe') { + f.refs = ['master', 'develop'] + } else { + f.refs = [f.ref] + } + return f + }) + }, + updateAdminSettings (state, { config, modifiedPaths }) { + state.loaded = true + state.dbConfigEnabled = true + state.config = config + state.modifiedPaths = modifiedPaths + }, + updateAdminDescriptions (state, { descriptions }) { + state.descriptions = descriptions + }, + updateAdminDraft (state, { path, value }) { + const [group, key, subkey] = path + const parent = [group, key, subkey] + + set(state.draft, path, value) + + // force-updating grouped draft to trigger refresh of group settings + if (path.length > parent.length) { + set(state.draft, parent, cloneDeep(get(state.draft, parent))) + } + }, + resetAdminDraft (state) { + state.draft = cloneDeep(state.config) + } + }, + actions: { + loadFrontendsStuff ({ state, rootState, dispatch, commit }) { + rootState.api.backendInteractor.fetchAvailableFrontends() + .then(frontends => commit('setAvailableFrontends', { frontends })) + }, + loadAdminStuff ({ state, rootState, dispatch, commit }) { + rootState.api.backendInteractor.fetchInstanceDBConfig() + .then(backendDbConfig => { + if (backendDbConfig.error) { + if (backendDbConfig.error.status === 400) { + backendDbConfig.error.json().then(errorJson => { + if (/configurable_from_database/.test(errorJson.error)) { + commit('setInstanceAdminNoDbConfig') + } + }) + } + } else { + dispatch('setInstanceAdminSettings', { backendDbConfig }) + } + }) + if (state.descriptions === null) { + rootState.api.backendInteractor.fetchInstanceConfigDescriptions() + .then(backendDescriptions => dispatch('setInstanceAdminDescriptions', { backendDescriptions })) + } + }, + setInstanceAdminSettings ({ state, commit, dispatch }, { backendDbConfig }) { + const config = state.config || {} + const modifiedPaths = new Set() + backendDbConfig.configs.forEach(c => { + const path = [c.group, c.key] + if (c.db) { + // Path elements can contain dot, therefore we use ' -> ' as a separator instead + // Using strings for modified paths for easier searching + c.db.forEach(x => modifiedPaths.add([...path, x].join(' -> '))) + } + const convert = (value) => { + if (Array.isArray(value) && value.length > 0 && value[0].tuple) { + return value.reduce((acc, c) => { + return { ...acc, [c.tuple[0]]: convert(c.tuple[1]) } + }, {}) + } else { + return value + } + } + set(config, path, convert(c.value)) + }) + console.log(config[':pleroma']) + commit('updateAdminSettings', { config, modifiedPaths }) + commit('resetAdminDraft') + }, + setInstanceAdminDescriptions ({ state, commit, dispatch }, { backendDescriptions }) { + const convert = ({ children, description, label, key = '', group, suggestions }, path, acc) => { + const newPath = group ? [group, key] : [key] + const obj = { description, label, suggestions } + if (Array.isArray(children)) { + children.forEach(c => { + convert(c, newPath, obj) + }) + } + set(acc, newPath, obj) + } + + const descriptions = {} + backendDescriptions.forEach(d => convert(d, '', descriptions)) + console.log(descriptions[':pleroma']['Pleroma.Captcha']) + commit('updateAdminDescriptions', { descriptions }) + }, + + // This action takes draft state, diffs it with live config state and then pushes + // only differences between the two. Difference detection only work up to subkey (third) level. + pushAdminDraft ({ rootState, state, commit, dispatch }) { + // TODO cleanup paths in modifiedPaths + const convert = (value) => { + if (typeof value !== 'object') { + return value + } else if (Array.isArray(value)) { + return value.map(convert) + } else { + return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] })) + } + } + + // Getting all group-keys used in config + const allGroupKeys = flatten( + Object + .entries(state.config) + .map( + ([group, lv1data]) => Object + .keys(lv1data) + .map((key) => ({ group, key })) + ) + ) + + // Only using group-keys where there are changes detected + const changedGroupKeys = allGroupKeys.filter(({ group, key }) => { + return !isEqual(state.config[group][key], state.draft[group][key]) + }) + + // Here we take all changed group-keys and get all changed subkeys + const changed = changedGroupKeys.map(({ group, key }) => { + const config = state.config[group][key] + const draft = state.draft[group][key] + + // We convert group-key value into entries arrays + const eConfig = Object.entries(config) + const eDraft = Object.entries(draft) + + // Then those entries array we diff so only changed subkey entries remain + // We use the diffed array to reconstruct the object and then shove it into convert() + return ({ group, key, value: convert(Object.fromEntries(differenceWith(eDraft, eConfig, isEqual))) }) + }) + + rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: changed + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + }, + pushAdminSetting ({ rootState, state, commit, dispatch }, { path, value }) { + const [group, key, ...rest] = Array.isArray(path) ? path : path.split(/\./g) + const clone = {} // not actually cloning the entire thing to avoid excessive writes + set(clone, rest, value) + + // TODO cleanup paths in modifiedPaths + const convert = (value) => { + if (typeof value !== 'object') { + return value + } else if (Array.isArray(value)) { + return value.map(convert) + } else { + return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] })) + } + } + + rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: [{ + group, + key, + value: convert(clone) + }] + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + }, + resetAdminSetting ({ rootState, state, commit, dispatch }, { path }) { + const [group, key, subkey] = path.split(/\./g) + + state.modifiedPaths.delete(path) + + return rootState.api.backendInteractor.pushInstanceDBConfig({ + payload: { + configs: [{ + group, + key, + delete: true, + subkeys: [subkey] + }] + } + }) + .then(() => rootState.api.backendInteractor.fetchInstanceDBConfig()) + .then(backendDbConfig => dispatch('setInstanceAdminSettings', { backendDbConfig })) + } + } +} + +export default adminSettingsStorage diff --git a/src/modules/config.js b/src/modules/config.js index 7597886e..56f8cba5 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,6 +1,7 @@ import Cookies from 'js-cookie' import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js' import messages from '../i18n/messages' +import { set } from 'lodash' import localeService from '../services/locale/locale.service.js' const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage' @@ -148,7 +149,7 @@ const config = { }, mutations: { setOption (state, { name, value }) { - state[name] = value + set(state, name, value) }, setHighlight (state, { user, color, type }) { const data = this.state.config.highlight[user] @@ -178,32 +179,52 @@ const config = { commit('setHighlight', { user, color, type }) }, setOption ({ commit, dispatch, state }, { name, value }) { - commit('setOption', { name, value }) - switch (name) { - case 'theme': - setPreset(value) - break - case 'sidebarColumnWidth': - case 'contentColumnWidth': - case 'notifsColumnWidth': - case 'emojiReactionsScale': - applyConfig(state) - break - case 'customTheme': - case 'customThemeSource': - applyTheme(value) - break - case 'interfaceLanguage': - messages.setLanguage(this.getters.i18n, value) - dispatch('loadUnicodeEmojiData', value) - Cookies.set( - BACKEND_LANGUAGE_COOKIE_NAME, - localeService.internalToBackendLocaleMulti(value) - ) - break - case 'thirdColumnMode': - dispatch('setLayoutWidth', undefined) - break + const exceptions = new Set([ + 'useStreamingApi' + ]) + + if (exceptions.has(name)) { + switch (name) { + case 'useStreamingApi': { + const action = value ? 'enableMastoSockets' : 'disableMastoSockets' + + dispatch(action).then(() => { + commit('setOption', { name: 'useStreamingApi', value }) + }).catch((e) => { + console.error('Failed starting MastoAPI Streaming socket', e) + dispatch('disableMastoSockets') + dispatch('setOption', { name: 'useStreamingApi', value: false }) + }) + } + } + } else { + commit('setOption', { name, value }) + switch (name) { + case 'theme': + setPreset(value) + break + case 'sidebarColumnWidth': + case 'contentColumnWidth': + case 'notifsColumnWidth': + case 'emojiReactionsScale': + applyConfig(state) + break + case 'customTheme': + case 'customThemeSource': + applyTheme(value) + break + case 'interfaceLanguage': + messages.setLanguage(this.getters.i18n, value) + dispatch('loadUnicodeEmojiData', value) + Cookies.set( + BACKEND_LANGUAGE_COOKIE_NAME, + localeService.internalToBackendLocaleMulti(value) + ) + break + case 'thirdColumnMode': + dispatch('setLayoutWidth', undefined) + break + } } } } diff --git a/src/modules/interface.js b/src/modules/interface.js index a86193ea..2d012f1b 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -1,7 +1,9 @@ const defaultState = { settingsModalState: 'hidden', - settingsModalLoaded: false, + settingsModalLoadedUser: false, + settingsModalLoadedAdmin: false, settingsModalTargetTab: null, + settingsModalMode: 'user', settings: { currentSaveStateNotice: null, noticeClearTimeout: null, @@ -54,10 +56,17 @@ const interfaceMod = { throw new Error('Illegal minimization state of settings modal') } }, - openSettingsModal (state) { + openSettingsModal (state, value) { + state.settingsModalMode = value state.settingsModalState = 'visible' - if (!state.settingsModalLoaded) { - state.settingsModalLoaded = true + if (value === 'user') { + if (!state.settingsModalLoadedUser) { + state.settingsModalLoadedUser = true + } + } else if (value === 'admin') { + if (!state.settingsModalLoadedAdmin) { + state.settingsModalLoadedAdmin = true + } } }, setSettingsModalTargetTab (state, value) { @@ -92,8 +101,8 @@ const interfaceMod = { closeSettingsModal ({ commit }) { commit('closeSettingsModal') }, - openSettingsModal ({ commit }) { - commit('openSettingsModal') + openSettingsModal ({ commit }, value = 'user') { + commit('openSettingsModal', value) }, togglePeekSettingsModal ({ commit }) { commit('togglePeekSettingsModal') diff --git a/src/modules/serverSideConfig.js b/src/modules/profileConfig.js similarity index 85% rename from src/modules/serverSideConfig.js rename to src/modules/profileConfig.js index 476263bc..2cb2014a 100644 --- a/src/modules/serverSideConfig.js +++ b/src/modules/profileConfig.js @@ -22,9 +22,9 @@ const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => { .updateNotificationSettings({ settings }) .then(result => { if (result.status === 'success') { - commit('confirmServerSideOption', { name, value }) + commit('confirmProfileOption', { name, value }) } else { - commit('confirmServerSideOption', { name, value: oldValue }) + commit('confirmProfileOption', { name, value: oldValue }) } }) } @@ -94,16 +94,16 @@ export const settingsMap = { export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null])) -const serverSideConfig = { +const profileConfig = { state: { ...defaultState }, mutations: { - confirmServerSideOption (state, { name, value }) { + confirmProfileOption (state, { name, value }) { set(state, name, value) }, - wipeServerSideOption (state, { name }) { + wipeProfileOption (state, { name }) { set(state, name, null) }, - wipeAllServerSideOptions (state) { + wipeAllProfileOptions (state) { Object.keys(settingsMap).forEach(key => { set(state, key, null) }) @@ -118,23 +118,23 @@ const serverSideConfig = { } }, actions: { - setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) { + setProfileOption ({ rootState, state, commit, dispatch }, { name, value }) { const oldValue = get(state, name) const map = settingsMap[name] if (!map) throw new Error('Invalid server-side setting') const { set: path = map, api = defaultApi } = map - commit('wipeServerSideOption', { name }) + commit('wipeProfileOption', { name }) api({ rootState, commit }, { path, value, oldValue }) .catch((e) => { console.warn('Error setting server-side option:', e) - commit('confirmServerSideOption', { name, value: oldValue }) + commit('confirmProfileOption', { name, value: oldValue }) }) }, logout ({ commit }) { - commit('wipeAllServerSideOptions') + commit('wipeAllProfileOptions') } } } -export default serverSideConfig +export default profileConfig diff --git a/src/modules/users.js b/src/modules/users.js index 7b41fab6..e976d875 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -577,6 +577,7 @@ const users = { loginUser (store, accessToken) { return new Promise((resolve, reject) => { const commit = store.commit + const dispatch = store.dispatch commit('beginLogin') store.rootState.api.backendInteractor.verifyCredentials(accessToken) .then((data) => { @@ -591,57 +592,57 @@ const users = { commit('setServerSideStorage', user) commit('addNewUsers', [user]) - store.dispatch('fetchEmoji') + dispatch('fetchEmoji') getNotificationPermission() .then(permission => commit('setNotificationPermission', permission)) // Set our new backend interactor commit('setBackendInteractor', backendInteractorService(accessToken)) - store.dispatch('pushServerSideStorage') + dispatch('pushServerSideStorage') if (user.token) { - store.dispatch('setWsToken', user.token) + dispatch('setWsToken', user.token) // Initialize the shout socket. - store.dispatch('initializeSocket') + dispatch('initializeSocket') } const startPolling = () => { // Start getting fresh posts. - store.dispatch('startFetchingTimeline', { timeline: 'friends' }) + dispatch('startFetchingTimeline', { timeline: 'friends' }) // Start fetching notifications - store.dispatch('startFetchingNotifications') + dispatch('startFetchingNotifications') // Start fetching chats - store.dispatch('startFetchingChats') + dispatch('startFetchingChats') } - store.dispatch('startFetchingLists') + dispatch('startFetchingLists') if (user.locked) { - store.dispatch('startFetchingFollowRequests') + dispatch('startFetchingFollowRequests') } if (store.getters.mergedConfig.useStreamingApi) { - store.dispatch('fetchTimeline', { timeline: 'friends', since: null }) - store.dispatch('fetchNotifications', { since: null }) - store.dispatch('enableMastoSockets', true).catch((error) => { + dispatch('fetchTimeline', { timeline: 'friends', since: null }) + dispatch('fetchNotifications', { since: null }) + dispatch('enableMastoSockets', true).catch((error) => { console.error('Failed initializing MastoAPI Streaming socket', error) }).then(() => { - store.dispatch('fetchChats', { latest: true }) - setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) + dispatch('fetchChats', { latest: true }) + setTimeout(() => dispatch('setNotificationsSilence', false), 10000) }) } else { startPolling() } // Get user mutes - store.dispatch('fetchMutes') + dispatch('fetchMutes') - store.dispatch('setLayoutWidth', windowWidth()) - store.dispatch('setLayoutHeight', windowHeight()) + dispatch('setLayoutWidth', windowWidth()) + dispatch('setLayoutHeight', windowHeight()) // Fetch our friends store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index e90723a1..ac715678 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -108,6 +108,11 @@ const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements' const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` +const PLEROMA_ADMIN_CONFIG_URL = '/api/pleroma/admin/config' +const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions' +const PLEROMA_ADMIN_FRONTENDS_URL = '/api/pleroma/admin/frontends' +const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL = '/api/pleroma/admin/frontends/install' + const oldfetch = window.fetch const fetch = (url, options) => { @@ -1668,6 +1673,94 @@ const setReportState = ({ id, state, credentials }) => { }) } +// ADMIN STUFF // EXPERIMENTAL +const fetchInstanceDBConfig = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_CONFIG_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const fetchInstanceConfigDescriptions = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_DESCRIPTIONS_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const fetchAvailableFrontends = ({ credentials }) => { + return fetch(PLEROMA_ADMIN_FRONTENDS_URL, { + headers: authHeaders(credentials) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const pushInstanceDBConfig = ({ credentials, payload }) => { + return fetch(PLEROMA_ADMIN_CONFIG_URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(credentials) + }, + method: 'POST', + body: JSON.stringify(payload) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + +const installFrontend = ({ credentials, payload }) => { + return fetch(PLEROMA_ADMIN_FRONTENDS_INSTALL_URL, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...authHeaders(credentials) + }, + method: 'POST', + body: JSON.stringify(payload) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return { + error: response + } + } + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1781,7 +1874,12 @@ const apiService = { postAnnouncement, editAnnouncement, deleteAnnouncement, - adminFetchAnnouncements + adminFetchAnnouncements, + fetchInstanceDBConfig, + fetchInstanceConfigDescriptions, + fetchAvailableFrontends, + pushInstanceDBConfig, + installFrontend } export default apiService diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js index 5182ecd1..b92c6c64 100644 --- a/src/services/file_type/file_type.service.js +++ b/src/services/file_type/file_type.service.js @@ -1,7 +1,7 @@ // TODO this func might as well take the entire file and use its mimetype // or the entire service could be just mimetype service that only operates // on mimetypes and not files. Currently the naming is confusing. -const fileType = mimetype => { +export const fileType = mimetype => { if (mimetype.match(/flash/)) { return 'flash' } @@ -25,11 +25,25 @@ const fileType = mimetype => { return 'unknown' } -const fileMatchesSomeType = (types, file) => +export const fileTypeExt = url => { + if (url.match(/\.(png|jpe?g|gif|webp|avif)$/)) { + return 'image' + } + if (url.match(/\.(ogv|mp4|webm|mov)$/)) { + return 'video' + } + if (url.match(/\.(it|s3m|mod|umx|mp3|aac|m4a|flac|alac|ogg|oga|opus|wav|ape|midi?)$/)) { + return 'audio' + } + return 'unknown' +} + +export const fileMatchesSomeType = (types, file) => types.some(type => fileType(file.mimetype) === type) const fileTypeService = { fileType, + fileTypeExt, fileMatchesSomeType }