diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css
index 03632dc8c..3be552d08 100644
--- a/web/source/css/_colors.css
+++ b/web/source/css/_colors.css
@@ -66,6 +66,10 @@ $button-bg: $blue2;
$button-fg: $gray1;
$button-hover-bg: $blue3;
+$button-danger-bg: $orange1;
+$button-danger-fg: $gray1;
+$button-danger-hover-bg: $orange2;
+
$toot-focus-bg: $gray5;
$toot-unfocus-bg: $gray3;
@@ -82,6 +86,7 @@ $boxshadow-border: 0.08rem solid $gray1;
$avatar-border: $orange2;
$input-bg: $gray4;
+$input-disabled-bg: $gray2;
$input-border: $blue1;
$input-focus-border: $blue3;
@@ -96,4 +101,7 @@ $settings-nav-bg-active: $orange1;
$settings-nav-fg-active: $gray1;
$error-fg: $error1;
-$error-bg: $error2;
\ No newline at end of file
+$error-bg: $error2;
+
+$settings-entry-bg: $gray3;
+$settings-entry-hover-bg: $gray4;
\ No newline at end of file
diff --git a/web/source/css/base.css b/web/source/css/base.css
index ddca1efa8..d50195465 100644
--- a/web/source/css/base.css
+++ b/web/source/css/base.css
@@ -162,6 +162,15 @@ main {
text-align: center;
font-family: 'Noto Sans', sans-serif;
+ &.danger {
+ color: $button-danger-fg;
+ background: $button-danger-bg;
+
+ &:hover {
+ background: $button-danger-hover-bg;
+ }
+ }
+
&:hover {
background: $button-hover-bg;
}
@@ -283,6 +292,10 @@ input, select, textarea {
&:focus {
border-color: $input-focus-border;
}
+
+ &:disabled {
+ background: $input-disabled-bg;
+ }
}
::placeholder {
@@ -290,11 +303,6 @@ input, select, textarea {
color: $fg-reduced
}
-input, textarea {
- padding-top: 0.1rem;
- padding-bottom: 0.1rem;
-}
-
hr {
color: transparent;
width: 100%;
diff --git a/web/source/package.json b/web/source/package.json
index 8b0bde878..1fa592883 100644
--- a/web/source/package.json
+++ b/web/source/package.json
@@ -18,6 +18,7 @@
"browserlist": "^1.0.1",
"create-error": "^0.3.1",
"css-extract": "^2.0.0",
+ "default-value": "^1.0.0",
"dotty": "^0.1.2",
"eslint-plugin-react": "^7.24.0",
"express": "^4.18.1",
@@ -25,6 +26,7 @@
"from2-string": "^1.1.0",
"icssify": "^2.0.0",
"is-plain-object": "^5.0.0",
+ "is-valid-domain": "^0.1.6",
"js-file-download": "^0.4.12",
"modern-normalize": "^1.1.0",
"photoswipe": "^5.3.0",
diff --git a/web/source/settings-panel/admin/federation.js b/web/source/settings-panel/admin/federation.js
index 23117be9f..1363938ff 100644
--- a/web/source/settings-panel/admin/federation.js
+++ b/web/source/settings-panel/admin/federation.js
@@ -21,12 +21,13 @@
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
-const {Switch, Route, Link, useRoute} = require("wouter");
+const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter");
+const fileDownload = require("js-file-download");
-const Submit = require("../components/submit");
+const { formFields } = require("../components/form-fields");
const api = require("../lib/api");
-const adminActions = require("../redux/reducers/instances").actions;
+const adminActions = require("../redux/reducers/admin").actions;
const base = "/settings/admin/federation";
@@ -39,26 +40,17 @@ const base = "/settings/admin/federation";
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
// const instance = Redux.useSelector(state => state.instances.adminSettings);
- const { blockedInstances } = Redux.useSelector(state => state.admin);
-
- const [errorMsg, setError] = React.useState("");
- const [statusMsg, setStatus] = React.useState("");
-
- const [loaded, setLoaded] = React.useState(false);
+ const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances);
React.useEffect(() => {
- if (blockedInstances != undefined) {
- setLoaded(true);
- } else {
+ if (!loadedBlockedInstances ) {
return Promise.try(() => {
return dispatch(api.admin.fetchDomainBlocks());
- }).then(() => {
- setLoaded(true);
});
}
}, []);
- if (!loaded) {
+ if (!loadedBlockedInstances) {
return (
Federation
@@ -68,28 +60,221 @@ module.exports = function AdminSettings() {
}
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
);
};
-function InstanceOverview({blockedInstances}) {
+function InstanceOverview() {
+ const [filter, setFilter] = React.useState("");
+ const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances);
+ const [_location, setLocation] = useLocation();
+
+ function filterFormSubmit(e) {
+ e.preventDefault();
+ setLocation(`${base}/${filter}`);
+ }
+
return (
-
+ <>
Federation
- {blockedInstances.map((entry) => {
- return (
-
-
{entry.domain}
-
- );
- })}
+ Here you can see an overview of blocked instances.
+
+
+
Blocked instances
+
+
+
+
+
+ >
+ );
+}
+
+const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock);
+function BulkBlocking() {
+ const dispatch = Redux.useDispatch();
+ const {bulkBlock, blockedInstances} = Redux.useSelector(state => state.admin);
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ function importBlocks() {
+ setStatus("Processing");
+ setError("");
+ return Promise.try(() => {
+ return dispatch(api.admin.bulkDomainBlock());
+ }).then(({success, invalidDomains}) => {
+ return Promise.try(() => {
+ return resetBulk();
+ }).then(() => {
+ dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")]));
+
+ let stat = "";
+ if (success == 0) {
+ return setError("No valid domains in import");
+ } else if (success == 1) {
+ stat = "Imported 1 domain";
+ } else {
+ stat = `Imported ${success} domains`;
+ }
+
+ if (invalidDomains.length > 0) {
+ if (invalidDomains.length == 1) {
+ stat += ", input contained 1 invalid domain.";
+ } else {
+ stat += `, input contained ${invalidDomains.length} invalid domains.`;
+ }
+ } else {
+ stat += "!";
+ }
+
+ setStatus(stat);
+ });
+ }).catch((e) => {
+ console.error(e);
+ setError(e.message);
+ setStatus("");
+ });
+ }
+
+ function exportBlocks() {
+ return Promise.try(() => {
+ setStatus("Exporting");
+ setError("");
+ let asJSON = bulkBlock.exportType.startsWith("json");
+ let _asCSV = bulkBlock.exportType.startsWith("csv");
+
+ let exportList = Object.values(blockedInstances).map((entry) => {
+ if (asJSON) {
+ return {
+ domain: entry.domain,
+ public_comment: entry.public_comment
+ };
+ } else {
+ return entry.domain;
+ }
+ });
+
+ if (bulkBlock.exportType == "json") {
+ return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)]));
+ } else if (bulkBlock.exportType == "json-download") {
+ return fileDownload(JSON.stringify(exportList), "block-export.json");
+ } else if (bulkBlock.exportType == "plain") {
+ return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")]));
+ }
+ }).then(() => {
+ setStatus("Exported!");
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ });
+ }
+
+ function resetBulk(e) {
+ if (e != undefined) {
+ e.preventDefault();
+ }
+ return dispatch(adminActions.resetBulkBlockVal());
+ }
+
+ function disableInfoFields(props={}) {
+ if (bulkBlock.list[0] == "[") {
+ return {
+ ...props,
+ disabled: true,
+ placeHolder: "Domain list is a JSON import, input disabled"
+ };
+ } else {
+ return props;
+ }
+ }
+
+ return (
+
+
Import / Export reset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }/>
+
+
+
+ {errorMsg.length > 0 &&
+
{errorMsg}
+ }
+ {statusMsg.length > 0 &&
+
{statusMsg}
+ }
+
+
);
}
@@ -102,27 +287,114 @@ function BackButton() {
);
}
-function InstancePage({blockedInstances}) {
+
+function InstancePageWrapped() {
+ /* We wrap the component to generate formFields with a setter depending on the domain
+ if formFields() is used inside the same component that is re-rendered with their state,
+ inputs get re-created on every change, causing them to lose focus, and bad performance
+ */
let [_match, {domain}] = useRoute(`${base}/:domain`);
- let [status, setStatus] = React.useState("");
- let [entry, setEntry] = React.useState(() => {
- let entry = blockedInstances.find((a) => a.domain == domain);
-
- if (entry == undefined) {
- setStatus(`No block entry found for ${domain}, but you can create one below:`);
- return {
- private_comment: ""
- };
+
+ if (domain == "view") { // from form field submission
+ let realDomain = (new URL(document.location)).searchParams.get("domain");
+ if (realDomain == undefined) {
+ return
;
} else {
- return entry;
+ domain = realDomain;
}
- });
+ }
+
+ function alterDomain([key, val]) {
+ return adminActions.updateDomainBlockVal([domain, key, val]);
+ }
+
+ const fields = formFields(alterDomain, (state) => state.admin.blockedInstances[domain]);
+
+ return ;
+}
+
+function InstancePage({domain, Form}) {
+ const dispatch = Redux.useDispatch();
+ const { blockedInstances } = Redux.useSelector(state => state.admin);
+ const entry = blockedInstances[domain];
+
+ React.useEffect(() => {
+ if (entry == undefined) {
+ return dispatch(adminActions.newDomainBlock(domain));
+ }
+ }, []);
+
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("");
+
+ if (entry == undefined) {
+ if (statusMsg == "removed") {
+ return ;
+ } else {
+ return "Loading...";
+ }
+ }
+
+ function submit() {
+ setStatus("PATCHing");
+ setError("");
+ return Promise.try(() => {
+ return dispatch(api.admin.updateDomainBlock(domain));
+ }).then(() => {
+ setStatus("Saved!");
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ });
+ }
+
+ function removeBlock() {
+ setStatus("removing");
+ setError("");
+ return Promise.try(() => {
+ return dispatch(api.admin.removeDomainBlock(domain));
+ }).then(() => {
+ setStatus("removed");
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ });
+ }
return (
- {status}
Federation settings for: {domain}
-
{entry.private_comment}
+ {entry.new && "No stored block yet, you can add one below:"}
+
+
+
+
+
+
+
+
+
+
+ {!entry.new &&
+
+ }
+
+ {errorMsg.length > 0 &&
+
{errorMsg}
+ }
+ {statusMsg.length > 0 &&
+
{statusMsg}
+ }
+
);
}
\ No newline at end of file
diff --git a/web/source/settings-panel/components/form-fields.jsx b/web/source/settings-panel/components/form-fields.jsx
index 67c6b883a..8c48ba1a9 100644
--- a/web/source/settings-panel/components/form-fields.jsx
+++ b/web/source/settings-panel/components/form-fields.jsx
@@ -36,28 +36,33 @@ function eventListeners(dispatch, setter, obj) {
};
},
- onFileChange: function (key) {
+ onFileChange: function (key, withPreview) {
return function (e) {
- let old = d.get(obj, key);
- if (old != undefined) {
- URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance
- }
let file = e.target.files[0];
- let objectURL = URL.createObjectURL(file);
- dispatch(setter([key, objectURL]));
+ if (withPreview) {
+ let old = d.get(obj, key);
+ if (old != undefined) {
+ URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance
+ }
+ let objectURL = URL.createObjectURL(file);
+ dispatch(setter([key, objectURL]));
+ }
dispatch(setter([`${key}File`, file]));
};
}
};
}
-function get(state, id) {
+function get(state, id, defaultVal) {
let value;
if (id.includes(".")) {
value = d.get(state, id);
} else {
value = state[id];
}
+ if (value == undefined) {
+ value = defaultVal;
+ }
return value;
}
@@ -71,7 +76,10 @@ function get(state, id) {
module.exports = {
formFields: function formFields(setter, selector) {
- function FormField({type, id, name, className="", placeHolder="", fileType="", children=null, options=null}) {
+ function FormField({
+ type, id, name, className="", placeHolder="", fileType="", children=null,
+ options=null, inputProps={}, withPreview=true
+ }) {
const dispatch = Redux.useDispatch();
let state = Redux.useSelector(selector);
let {
@@ -83,14 +91,14 @@ module.exports = {
let field;
let defaultLabel = true;
if (type == "text") {
- field = ;
+ field = ;
} else if (type == "textarea") {
- field =