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

+
+ setFilter(e.target.value)}/> + Add block +
+
+ {Object.values(blockedInstances).filter((a) => a.domain.startsWith(filter)).map((entry) => { + return ( + + + + {entry.domain} + + + {new Date(entry.created_at).toLocaleString()} + + + + ); + })} +
+
+ + + + ); +} + +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 =