diff options
Diffstat (limited to 'web/source/settings-panel')
29 files changed, 3452 insertions, 0 deletions
diff --git a/web/source/settings-panel/admin/actions.js b/web/source/settings-panel/admin/actions.js new file mode 100644 index 000000000..d4980d021 --- /dev/null +++ b/web/source/settings-panel/admin/actions.js @@ -0,0 +1,61 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); + +const Submit = require("../components/submit"); + +const api = require("../lib/api"); +const submit = require("../lib/submit"); + +module.exports = function AdminActionPanel() { + const dispatch = Redux.useDispatch(); + + const [days, setDays] = React.useState(30); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + const removeMedia = submit( + () => dispatch(api.admin.mediaCleanup(days)), + {setStatus, setError} + ); + + return ( + <> + <h1>Admin Actions</h1> + <div> + <h2>Media cleanup</h2> + <p> + Clean up remote media older than the specified number of days. + If the remote instance is still online they will be refetched when needed. + Also cleans up unused headers and avatars from the media cache. + </p> + <div> + <label htmlFor="days">Days: </label> + <input id="days" type="number" value={days} onChange={(e) => setDays(e.target.value)}/> + </div> + <Submit onClick={removeMedia} label="Remove media" errorMsg={errorMsg} statusMsg={statusMsg} /> + </div> + </> + ); +};
\ No newline at end of file diff --git a/web/source/settings-panel/admin/emoji.js b/web/source/settings-panel/admin/emoji.js new file mode 100644 index 000000000..1ef4a54a3 --- /dev/null +++ b/web/source/settings-panel/admin/emoji.js @@ -0,0 +1,212 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); +const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter"); + +const Submit = require("../components/submit"); +const FakeToot = require("../components/fake-toot"); +const { formFields } = require("../components/form-fields"); + +const api = require("../lib/api"); +const adminActions = require("../redux/reducers/admin").actions; +const submit = require("../lib/submit"); + +const base = "/settings/admin/custom-emoji"; + +module.exports = function CustomEmoji() { + return ( + <Switch> + <Route path={`${base}/:emojiId`}> + <EmojiDetailWrapped /> + </Route> + <EmojiOverview /> + </Switch> + ); +}; + +function EmojiOverview() { + const dispatch = Redux.useDispatch(); + const [loaded, setLoaded] = React.useState(false); + + const [errorMsg, setError] = React.useState(""); + + React.useEffect(() => { + if (!loaded) { + Promise.try(() => { + return dispatch(api.admin.fetchCustomEmoji()); + }).then(() => { + setLoaded(true); + }).catch((e) => { + setLoaded(true); + setError(e.message); + }); + } + }, []); + + if (!loaded) { + return ( + <> + <h1>Custom Emoji</h1> + Loading... + </> + ); + } + + return ( + <> + <h1>Custom Emoji</h1> + <EmojiList/> + <NewEmoji/> + {errorMsg.length > 0 && + <div className="error accent">{errorMsg}</div> + } + </> + ); +} + +const NewEmojiForm = formFields(adminActions.updateNewEmojiVal, (state) => state.admin.newEmoji); +function NewEmoji() { + const dispatch = Redux.useDispatch(); + const newEmojiForm = Redux.useSelector((state) => state.admin.newEmoji); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + const uploadEmoji = submit( + () => dispatch(api.admin.newEmoji()), + { + setStatus, setError, + onSuccess: function() { + URL.revokeObjectURL(newEmojiForm.image); + return Promise.all([ + dispatch(adminActions.updateNewEmojiVal(["image", undefined])), + dispatch(adminActions.updateNewEmojiVal(["imageFile", undefined])), + dispatch(adminActions.updateNewEmojiVal(["shortcode", ""])), + ]); + } + } + ); + + React.useEffect(() => { + if (newEmojiForm.shortcode.length == 0) { + if (newEmojiForm.imageFile != undefined) { + let [name, ext] = newEmojiForm.imageFile.name.split("."); + dispatch(adminActions.updateNewEmojiVal(["shortcode", name])); + } + } + }); + + let emojiOrShortcode = `:${newEmojiForm.shortcode}:`; + + if (newEmojiForm.image != undefined) { + emojiOrShortcode = <img + className="emoji" + src={newEmojiForm.image} + title={`:${newEmojiForm.shortcode}:`} + alt={newEmojiForm.shortcode} + />; + } + + return ( + <div> + <h2>Add new custom emoji</h2> + + <FakeToot> + Look at this new custom emoji {emojiOrShortcode} isn't it cool? + </FakeToot> + + <NewEmojiForm.File + id="image" + name="Image" + fileType="image/png,image/gif" + showSize={true} + maxSize={50 * 1000} + /> + + <NewEmojiForm.TextInput + id="shortcode" + name="Shortcode (without : :), must be unique on the instance" + placeHolder="blobcat" + /> + + <Submit onClick={uploadEmoji} label="Upload" errorMsg={errorMsg} statusMsg={statusMsg} /> + </div> + ); +} + +function EmojiList() { + const emoji = Redux.useSelector((state) => state.admin.emoji); + + return ( + <div> + <h2>Overview</h2> + <div className="list emoji-list"> + {Object.entries(emoji).map(([category, entries]) => { + return <EmojiCategory key={category} category={category} entries={entries}/>; + })} + </div> + </div> + ); +} + +function EmojiCategory({category, entries}) { + return ( + <div className="entry"> + <b>{category}</b> + <div className="emoji-group"> + {entries.map((e) => { + return ( + // <Link key={e.static_url} to={`${base}/${e.shortcode}`}> + <Link key={e.static_url} to={`${base}`}> + <a> + <img src={e.static_url} alt={e.shortcode} title={`:${e.shortcode}:`}/> + </a> + </Link> + ); + })} + </div> + </div> + ); +} + +function EmojiDetailWrapped() { + /* 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, {emojiId}] = useRoute(`${base}/:emojiId`); + + function alterEmoji([key, val]) { + return adminActions.updateDomainBlockVal([emojiId, key, val]); + } + + const fields = formFields(alterEmoji, (state) => state.admin.blockedInstances[emojiId]); + + return <EmojiDetail id={emojiId} Form={fields} />; +} + +function EmojiDetail({id, Form}) { + return ( + "Not implemented yet" + ); +}
\ No newline at end of file diff --git a/web/source/settings-panel/admin/federation.js b/web/source/settings-panel/admin/federation.js new file mode 100644 index 000000000..7afc3c699 --- /dev/null +++ b/web/source/settings-panel/admin/federation.js @@ -0,0 +1,382 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); +const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter"); +const fileDownload = require("js-file-download"); + +const { formFields } = require("../components/form-fields"); + +const api = require("../lib/api"); +const adminActions = require("../redux/reducers/admin").actions; +const submit = require("../lib/submit"); + +const base = "/settings/admin/federation"; + +// const { +// TextInput, +// TextArea, +// File +// } = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings); + +module.exports = function AdminSettings() { + const dispatch = Redux.useDispatch(); + // const instance = Redux.useSelector(state => state.instances.adminSettings); + const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances); + + React.useEffect(() => { + if (!loadedBlockedInstances ) { + Promise.try(() => { + return dispatch(api.admin.fetchDomainBlocks()); + }); + } + }, []); + + if (!loadedBlockedInstances) { + return ( + <div> + <h1>Federation</h1> + Loading... + </div> + ); + } + + return ( + <Switch> + <Route path={`${base}/:domain`}> + <InstancePageWrapped /> + </Route> + <InstanceOverview /> + </Switch> + ); +}; + +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 ( + <> + <h1>Federation</h1> + Here you can see an overview of blocked instances. + + <div className="instance-list"> + <h2>Blocked instances</h2> + <form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}> + <input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)}/> + <Link to={`${base}/${filter}`}><a className="button">Add block</a></Link> + </form> + <div className="list"> + {Object.values(blockedInstances).filter((a) => a.domain.startsWith(filter)).map((entry) => { + return ( + <Link key={entry.domain} to={`${base}/${entry.domain}`}> + <a className="entry nounderline"> + <span id="domain"> + {entry.domain} + </span> + <span id="date"> + {new Date(entry.created_at).toLocaleString()} + </span> + </a> + </Link> + ); + })} + </div> + </div> + + <BulkBlocking/> + </> + ); +} + +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 ( + <div className="bulk"> + <h2>Import / Export <a onClick={resetBulk}>reset</a></h2> + <Bulk.TextArea + id="list" + name="Domains, one per line" + placeHolder={`google.com\nfacebook.com`} + /> + + <Bulk.TextArea + id="public_comment" + name="Public comment" + inputProps={disableInfoFields({rows: 3})} + /> + + <Bulk.TextArea + id="private_comment" + name="Private comment" + inputProps={disableInfoFields({rows: 3})} + /> + + <Bulk.Checkbox + id="obfuscate" + name="Obfuscate domains? " + inputProps={disableInfoFields()} + /> + + <div className="hidden"> + <Bulk.File + id="json" + fileType="application/json" + withPreview={false} + /> + </div> + + <div className="messagebutton"> + <div> + <button type="submit" onClick={importBlocks}>Import</button> + </div> + + <div> + <button type="submit" onClick={exportBlocks}>Export</button> + + <Bulk.Select id="exportType" name="Export type" options={ + <> + <option value="plain">One per line in text field</option> + <option value="json">JSON in text field</option> + <option value="json-download">JSON file download</option> + <option disabled value="csv">CSV in text field (glitch-soc)</option> + <option disabled value="csv-download">CSV file download (glitch-soc)</option> + </> + }/> + </div> + <br/> + <div> + {errorMsg.length > 0 && + <div className="error accent">{errorMsg}</div> + } + {statusMsg.length > 0 && + <div className="accent">{statusMsg}</div> + } + </div> + </div> + </div> + ); +} + +function BackButton() { + return ( + <Link to={base}> + <a className="button">< back</a> + </Link> + ); +} + +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`); + + if (domain == "view") { // from form field submission + let realDomain = (new URL(document.location)).searchParams.get("domain"); + if (realDomain == undefined) { + return <Redirect to={base}/>; + } else { + domain = realDomain; + } + } + + function alterDomain([key, val]) { + return adminActions.updateDomainBlockVal([domain, key, val]); + } + + const fields = formFields(alterDomain, (state) => state.admin.newInstanceBlocks[domain]); + + return <InstancePage domain={domain} Form={fields} />; +} + +function InstancePage({domain, Form}) { + const dispatch = Redux.useDispatch(); + const entry = Redux.useSelector(state => state.admin.newInstanceBlocks[domain]); + const [_location, setLocation] = useLocation(); + + React.useEffect(() => { + if (entry == undefined) { + dispatch(api.admin.getEditableDomainBlock(domain)); + } + }, []); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + if (entry == undefined) { + return "Loading..."; + } + + const updateBlock = submit( + () => dispatch(api.admin.updateDomainBlock(domain)), + {setStatus, setError} + ); + + const removeBlock = submit( + () => dispatch(api.admin.removeDomainBlock(domain)), + {setStatus, setError, startStatus: "Removing", successStatus: "Removed!", onSuccess: () => { + setLocation(base); + }} + ); + + return ( + <div> + <h1><BackButton/> Federation settings for: {domain}</h1> + {entry.new && "No stored block yet, you can add one below:"} + + <Form.TextArea + id="public_comment" + name="Public comment" + /> + + <Form.TextArea + id="private_comment" + name="Private comment" + /> + + <Form.Checkbox + id="obfuscate" + name="Obfuscate domain? " + /> + + <div className="messagebutton"> + <button type="submit" onClick={updateBlock}>{entry.new ? "Add block" : "Save block"}</button> + + {!entry.new && + <button className="danger" onClick={removeBlock}>Remove block</button> + } + + {errorMsg.length > 0 && + <div className="error accent">{errorMsg}</div> + } + {statusMsg.length > 0 && + <div className="accent">{statusMsg}</div> + } + </div> + </div> + ); +}
\ No newline at end of file diff --git a/web/source/settings-panel/admin/settings.js b/web/source/settings-panel/admin/settings.js new file mode 100644 index 000000000..845a1f924 --- /dev/null +++ b/web/source/settings-panel/admin/settings.js @@ -0,0 +1,110 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); + +const Submit = require("../components/submit"); + +const api = require("../lib/api"); +const submit = require("../lib/submit"); + +const adminActions = require("../redux/reducers/instances").actions; + +const { + TextInput, + TextArea, + File +} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings); + +module.exports = function AdminSettings() { + const dispatch = Redux.useDispatch(); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + const updateSettings = submit( + () => dispatch(api.admin.updateInstance()), + {setStatus, setError} + ); + + return ( + <div> + <h1>Instance Settings</h1> + <TextInput + id="title" + name="Title" + placeHolder="My GoToSocial instance" + /> + + <TextArea + id="short_description" + name="Short description" + placeHolder="A small testing instance for the GoToSocial alpha." + /> + <TextArea + id="description" + name="Full description" + placeHolder="A small testing instance for the GoToSocial alpha." + /> + + <TextInput + id="contact_account.username" + name="Contact user (local account username)" + placeHolder="admin" + /> + <TextInput + id="email" + name="Contact email" + placeHolder="admin@example.com" + /> + + <TextArea + id="terms" + name="Terms & Conditions" + placeHolder="" + /> + + {/* <div className="file-upload"> + <h3>Instance avatar</h3> + <div> + <img className="preview avatar" src={instance.avatar} alt={instance.avatar ? `Avatar image for the instance` : "No instance avatar image set"} /> + <File + id="avatar" + fileType="image/*" + /> + </div> + </div> + + <div className="file-upload"> + <h3>Instance header</h3> + <div> + <img className="preview header" src={instance.header} alt={instance.header ? `Header image for the instance` : "No instance header image set"} /> + <File + id="header" + fileType="image/*" + /> + </div> + </div> */} + <Submit onClick={updateSettings} label="Save" errorMsg={errorMsg} statusMsg={statusMsg} /> + </div> + ); +};
\ No newline at end of file diff --git a/web/source/settings-panel/components/error.jsx b/web/source/settings-panel/components/error.jsx new file mode 100644 index 000000000..13dc686b7 --- /dev/null +++ b/web/source/settings-panel/components/error.jsx @@ -0,0 +1,45 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); + +module.exports = function ErrorFallback({error, resetErrorBoundary}) { + return ( + <div className="error"> + <p> + {"An error occured, please report this on the "} + <a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a> + {" or "} + <a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>. + <br/>Include the details below: + </p> + <pre> + {error.name}: {error.message} + </pre> + <pre> + {error.stack} + </pre> + <p> + <button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a> + </p> + </div> + ); +};
\ No newline at end of file diff --git a/web/source/settings-panel/components/fake-toot.jsx b/web/source/settings-panel/components/fake-toot.jsx new file mode 100644 index 000000000..f79e24eb9 --- /dev/null +++ b/web/source/settings-panel/components/fake-toot.jsx @@ -0,0 +1,43 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const Redux = require("react-redux"); + +module.exports = function FakeToot({children}) { + const account = Redux.useSelector((state) => state.user.profile); + + return ( + <div className="toot expanded"> + <div className="contentgrid"> + <span className="avatar"> + <img src={account.avatar} alt=""/> + </span> + <span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span> + <span className="username">@{account.username}</span> + <div className="text"> + <div className="content"> + {children} + </div> + </div> + </div> + </div> + ); +};
\ 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 new file mode 100644 index 000000000..cb402c3b2 --- /dev/null +++ b/web/source/settings-panel/components/form-fields.jsx @@ -0,0 +1,167 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const Redux = require("react-redux"); +const d = require("dotty"); +const prettierBytes = require("prettier-bytes"); + +function eventListeners(dispatch, setter, obj) { + return { + onTextChange: function (key) { + return function (e) { + dispatch(setter([key, e.target.value])); + }; + }, + + onCheckChange: function (key) { + return function (e) { + dispatch(setter([key, e.target.checked])); + }; + }, + + onFileChange: function (key, withPreview) { + return function (e) { + let file = e.target.files[0]; + 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, defaultVal) { + let value; + if (id.includes(".")) { + value = d.get(state, id); + } else { + value = state[id]; + } + if (value == undefined) { + value = defaultVal; + } + return value; +} + +// function removeFile(name) { +// return function(e) { +// e.preventDefault(); +// dispatch(user.setProfileVal([name, ""])); +// dispatch(user.setProfileVal([`${name}File`, ""])); +// }; +// } + +module.exports = { + formFields: function formFields(setter, selector) { + function FormField({ + type, id, name, className="", placeHolder="", fileType="", children=null, + options=null, inputProps={}, withPreview=true, showSize=false, maxSize=Infinity + }) { + const dispatch = Redux.useDispatch(); + let state = Redux.useSelector(selector); + let { + onTextChange, + onCheckChange, + onFileChange + } = eventListeners(dispatch, setter, state); + + let field; + let defaultLabel = true; + if (type == "text") { + field = <input type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} {...inputProps}/>; + } else if (type == "textarea") { + field = <textarea type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} rows={8} {...inputProps}/>; + } else if (type == "checkbox") { + field = <input type="checkbox" id={id} checked={get(state, id, false)} className={className} onChange={onCheckChange(id)} {...inputProps}/>; + } else if (type == "select") { + field = ( + <select id={id} value={get(state, id, "")} className={className} onChange={onTextChange(id)} {...inputProps}> + {options} + </select> + ); + } else if (type == "file") { + defaultLabel = false; + let file = get(state, `${id}File`); + + let size = null; + if (showSize && file) { + size = `(${prettierBytes(file.size)})`; + + if (file.size > maxSize) { + size = <span className="error-text">{size}</span>; + } + } + + field = ( + <> + <label htmlFor={id} className="file-input button">Browse</label> + <span> + {file ? file.name : "no file selected"} {size} + </span> + {/* <a onClick={removeFile("header")}>remove</a> */} + <input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/> + </> + ); + } else { + defaultLabel = false; + field = `unsupported FormField ${type}, this is a developer error`; + } + + let label = <label htmlFor={id}>{name}</label>; + return ( + <div className={`form-field ${type}`}> + {defaultLabel ? label : null} {field} + {children} + </div> + ); + } + + return { + TextInput: function(props) { + return <FormField type="text" {...props} />; + }, + + TextArea: function(props) { + return <FormField type="textarea" {...props} />; + }, + + Checkbox: function(props) { + return <FormField type="checkbox" {...props} />; + }, + + Select: function(props) { + return <FormField type="select" {...props} />; + }, + + File: function(props) { + return <FormField type="file" {...props} />; + }, + }; + }, + + eventListeners +};
\ No newline at end of file diff --git a/web/source/settings-panel/components/languages.jsx b/web/source/settings-panel/components/languages.jsx new file mode 100644 index 000000000..1522495da --- /dev/null +++ b/web/source/settings-panel/components/languages.jsx @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const React = require("react"); + +module.exports = function Languages() { + return <React.Fragment> + <option value="AF">Afrikaans</option> + <option value="SQ">Albanian</option> + <option value="AR">Arabic</option> + <option value="HY">Armenian</option> + <option value="EU">Basque</option> + <option value="BN">Bengali</option> + <option value="BG">Bulgarian</option> + <option value="CA">Catalan</option> + <option value="KM">Cambodian</option> + <option value="ZH">Chinese (Mandarin)</option> + <option value="HR">Croatian</option> + <option value="CS">Czech</option> + <option value="DA">Danish</option> + <option value="NL">Dutch</option> + <option value="EN">English</option> + <option value="ET">Estonian</option> + <option value="FJ">Fiji</option> + <option value="FI">Finnish</option> + <option value="FR">French</option> + <option value="KA">Georgian</option> + <option value="DE">German</option> + <option value="EL">Greek</option> + <option value="GU">Gujarati</option> + <option value="HE">Hebrew</option> + <option value="HI">Hindi</option> + <option value="HU">Hungarian</option> + <option value="IS">Icelandic</option> + <option value="ID">Indonesian</option> + <option value="GA">Irish</option> + <option value="IT">Italian</option> + <option value="JA">Japanese</option> + <option value="JW">Javanese</option> + <option value="KO">Korean</option> + <option value="LA">Latin</option> + <option value="LV">Latvian</option> + <option value="LT">Lithuanian</option> + <option value="MK">Macedonian</option> + <option value="MS">Malay</option> + <option value="ML">Malayalam</option> + <option value="MT">Maltese</option> + <option value="MI">Maori</option> + <option value="MR">Marathi</option> + <option value="MN">Mongolian</option> + <option value="NE">Nepali</option> + <option value="NO">Norwegian</option> + <option value="FA">Persian</option> + <option value="PL">Polish</option> + <option value="PT">Portuguese</option> + <option value="PA">Punjabi</option> + <option value="QU">Quechua</option> + <option value="RO">Romanian</option> + <option value="RU">Russian</option> + <option value="SM">Samoan</option> + <option value="SR">Serbian</option> + <option value="SK">Slovak</option> + <option value="SL">Slovenian</option> + <option value="ES">Spanish</option> + <option value="SW">Swahili</option> + <option value="SV">Swedish </option> + <option value="TA">Tamil</option> + <option value="TT">Tatar</option> + <option value="TE">Telugu</option> + <option value="TH">Thai</option> + <option value="BO">Tibetan</option> + <option value="TO">Tonga</option> + <option value="TR">Turkish</option> + <option value="UK">Ukrainian</option> + <option value="UR">Urdu</option> + <option value="UZ">Uzbek</option> + <option value="VI">Vietnamese</option> + <option value="CY">Welsh</option> + <option value="XH">Xhosa</option> + </React.Fragment>; +}; diff --git a/web/source/settings-panel/components/login.jsx b/web/source/settings-panel/components/login.jsx new file mode 100644 index 000000000..c67e99acd --- /dev/null +++ b/web/source/settings-panel/components/login.jsx @@ -0,0 +1,102 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); + +const { setInstance } = require("../redux/reducers/oauth").actions; +const api = require("../lib/api"); + +module.exports = function Login({error}) { + const dispatch = Redux.useDispatch(); + const [ instanceField, setInstanceField ] = React.useState(""); + const [ errorMsg, setErrorMsg ] = React.useState(); + const instanceFieldRef = React.useRef(""); + + React.useEffect(() => { + // check if current domain runs an instance + let currentDomain = window.location.origin; + Promise.try(() => { + return dispatch(api.instance.fetchWithoutStore(currentDomain)); + }).then(() => { + if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet + dispatch(setInstance(currentDomain)); + instanceFieldRef.current = currentDomain; + setInstanceField(currentDomain); + } + }).catch((e) => { + console.log("Current domain does not host a valid instance: ", e); + }); + }, []); + + function tryInstance() { + let domain = instanceFieldRef.current; + Promise.try(() => { + return dispatch(api.instance.fetchWithoutStore(domain)).catch((e) => { + // TODO: clearer error messages for common errors + console.log(e); + throw e; + }); + }).then(() => { + dispatch(setInstance(domain)); + + return dispatch(api.oauth.register()).catch((e) => { + console.log(e); + throw e; + }); + }).then(() => { + return dispatch(api.oauth.authorize()); // will send user off-page + }).catch((e) => { + setErrorMsg( + <> + <b>{e.type}</b> + <span>{e.message}</span> + </> + ); + }); + } + + function updateInstanceField(e) { + if (e.key == "Enter") { + tryInstance(instanceField); + } else { + setInstanceField(e.target.value); + instanceFieldRef.current = e.target.value; + } + } + + return ( + <section className="login"> + <h1>OAUTH Login:</h1> + {error} + <form onSubmit={(e) => e.preventDefault()}> + <label htmlFor="instance">Instance: </label> + <input value={instanceField} onChange={updateInstanceField} id="instance"/> + {errorMsg && + <div className="error"> + {errorMsg} + </div> + } + <button onClick={tryInstance}>Authenticate</button> + </form> + </section> + ); +};
\ No newline at end of file diff --git a/web/source/settings-panel/components/nav-button.jsx b/web/source/settings-panel/components/nav-button.jsx new file mode 100644 index 000000000..3c76711fb --- /dev/null +++ b/web/source/settings-panel/components/nav-button.jsx @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const { Link, useRoute } = require("wouter"); + +module.exports = function NavButton({href, name}) { + const [isActive] = useRoute(`${href}/:anything?`); + return ( + <Link href={href}> + <a className={isActive ? "active" : ""} data-content={name}> + {name} + </a> + </Link> + ); +};
\ No newline at end of file diff --git a/web/source/settings-panel/components/submit.jsx b/web/source/settings-panel/components/submit.jsx new file mode 100644 index 000000000..0187fc81f --- /dev/null +++ b/web/source/settings-panel/components/submit.jsx @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const React = require("react"); + +module.exports = function Submit({onClick, label, errorMsg, statusMsg}) { + return ( + <div className="messagebutton"> + <button type="submit" onClick={onClick}>{ label }</button> + {errorMsg.length > 0 && + <div className="error accent">{errorMsg}</div> + } + {statusMsg.length > 0 && + <div className="accent">{statusMsg}</div> + } + </div> + ); +}; diff --git a/web/source/settings-panel/index.js b/web/source/settings-panel/index.js new file mode 100644 index 000000000..34720e818 --- /dev/null +++ b/web/source/settings-panel/index.js @@ -0,0 +1,178 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const ReactDom = require("react-dom/client"); +const Redux = require("react-redux"); +const { Switch, Route, Redirect } = require("wouter"); +const { Provider } = require("react-redux"); +const { PersistGate } = require("redux-persist/integration/react"); + +const { store, persistor } = require("./redux"); +const api = require("./lib/api"); +const oauth = require("./redux/reducers/oauth").actions; +const { AuthenticationError } = require("./lib/errors"); + +const Login = require("./components/login"); + +require("./style.css"); + +// TODO: nested categories? +const nav = { + "User": { + "Profile": require("./user/profile.js"), + "Settings": require("./user/settings.js"), + }, + "Admin": { + adminOnly: true, + "Instance Settings": require("./admin/settings.js"), + "Actions": require("./admin/actions"), + "Federation": require("./admin/federation.js"), + "Custom Emoji": require("./admin/emoji.js"), + } +}; + +const { sidebar, panelRouter } = require("./lib/get-views")(nav); + +function App() { + const dispatch = Redux.useDispatch(); + + const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth); + const reduxTempStatus = Redux.useSelector((state) => state.temporary.status); + + const [errorMsg, setErrorMsg] = React.useState(); + const [tokenChecked, setTokenChecked] = React.useState(false); + + React.useEffect(() => { + if (loginState == "login" || loginState == "callback") { + Promise.try(() => { + // Process OAUTH authorization token from URL if available + if (loginState == "callback") { + let urlParams = new URLSearchParams(window.location.search); + let code = urlParams.get("code"); + + if (code == undefined) { + setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:")); + } else { + return dispatch(api.oauth.tokenize(code)); + } + } + }).then(() => { + // Fetch current instance info + return dispatch(api.instance.fetch()); + }).then(() => { + // Check currently stored auth token for validity if available + return dispatch(api.user.fetchAccount()); + }).then(() => { + setTokenChecked(true); + + return dispatch(api.oauth.checkIfAdmin()); + }).catch((e) => { + if (e instanceof AuthenticationError) { + dispatch(oauth.remove()); + e.message = "Stored OAUTH token no longer valid, please log in again."; + } + setErrorMsg(e); + console.error(e); + }); + } + }, []); + + let ErrorElement = null; + if (errorMsg != undefined) { + ErrorElement = ( + <div className="error"> + <b>{errorMsg.type}</b> + <span>{errorMsg.message}</span> + </div> + ); + } + + const LogoutElement = ( + <button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}> + Log out + </button> + ); + + if (reduxTempStatus != undefined) { + return ( + <section> + {reduxTempStatus} + </section> + ); + } else if (tokenChecked && loginState == "login") { + return ( + <> + <div className="sidebar"> + {sidebar.all} + {isAdmin && sidebar.admin} + {LogoutElement} + </div> + <section className="with-sidebar"> + {ErrorElement} + <Switch> + {panelRouter.all} + {isAdmin && panelRouter.admin} + <Route> {/* default route */} + <Redirect to="/settings/user" /> + </Route> + </Switch> + </section> + </> + ); + } else if (loginState == "none") { + return ( + <Login error={ErrorElement} /> + ); + } else { + let status; + + if (loginState == "login") { + status = "Verifying stored login..."; + } else if (loginState == "callback") { + status = "Processing OAUTH callback..."; + } + + return ( + <section> + <div> + {status} + </div> + {ErrorElement} + {LogoutElement} + </section> + ); + } + +} + +function Main() { + return ( + <Provider store={store}> + <PersistGate loading={"loading..."} persistor={persistor}> + <App /> + </PersistGate> + </Provider> + ); +} + +const root = ReactDom.createRoot(document.getElementById("root")); +root.render(<React.StrictMode><Main /></React.StrictMode>);
\ No newline at end of file diff --git a/web/source/settings-panel/lib/api/admin.js b/web/source/settings-panel/lib/api/admin.js new file mode 100644 index 000000000..1df47b693 --- /dev/null +++ b/web/source/settings-panel/lib/api/admin.js @@ -0,0 +1,192 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const isValidDomain = require("is-valid-domain"); + +const instance = require("../../redux/reducers/instances").actions; +const admin = require("../../redux/reducers/admin").actions; + +module.exports = function ({ apiCall, getChanges }) { + const adminAPI = { + updateInstance: function updateInstance() { + return function (dispatch, getState) { + return Promise.try(() => { + const state = getState().instances.adminSettings; + + const update = getChanges(state, { + formKeys: ["title", "short_description", "description", "contact_account.username", "email", "terms"], + renamedKeys: {"contact_account.username": "contact_username"}, + // fileKeys: ["avatar", "header"] + }); + + return dispatch(apiCall("PATCH", "/api/v1/instance", update, "form")); + }).then((data) => { + return dispatch(instance.setInstanceInfo(data)); + }); + }; + }, + + fetchDomainBlocks: function fetchDomainBlocks() { + return function (dispatch, _getState) { + return Promise.try(() => { + return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks")); + }).then((data) => { + return dispatch(admin.setBlockedInstances(data)); + }); + }; + }, + + updateDomainBlock: function updateDomainBlock(domain) { + return function (dispatch, getState) { + return Promise.try(() => { + const state = getState().admin.newInstanceBlocks[domain]; + const update = getChanges(state, { + formKeys: ["domain", "obfuscate", "public_comment", "private_comment"], + }); + + return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks", update, "form")); + }).then((block) => { + return Promise.all([ + dispatch(admin.newDomainBlock([domain, block])), + dispatch(admin.setDomainBlock([domain, block])) + ]); + }); + }; + }, + + getEditableDomainBlock: function getEditableDomainBlock(domain) { + return function (dispatch, getState) { + let data = getState().admin.blockedInstances[domain]; + return dispatch(admin.newDomainBlock([domain, data])); + }; + }, + + bulkDomainBlock: function bulkDomainBlock() { + return function (dispatch, getState) { + let invalidDomains = []; + let success = 0; + + return Promise.try(() => { + const state = getState().admin.bulkBlock; + let list = state.list; + let domains; + + let fields = getChanges(state, { + formKeys: ["obfuscate", "public_comment", "private_comment"] + }); + + let defaultDate = new Date().toUTCString(); + + if (list[0] == "[") { + domains = JSON.parse(state.list); + } else { + domains = list.split("\n").map((line_) => { + let line = line_.trim(); + if (line.length == 0) { + return null; + } + + if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) { + invalidDomains.push(line); + return null; + } + + return { + domain: line, + created_at: defaultDate, + ...fields + }; + }).filter((a) => a != null); + } + + if (domains.length == 0) { + return; + } + + const update = { + domains: new Blob([JSON.stringify(domains)], {type: "application/json"}) + }; + + return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form")); + }).then((blocks) => { + if (blocks != undefined) { + return Promise.each(blocks, (block) => { + success += 1; + return dispatch(admin.setDomainBlock([block.domain, block])); + }); + } + }).then(() => { + return { + success, + invalidDomains + }; + }); + }; + }, + + removeDomainBlock: function removeDomainBlock(domain) { + return function (dispatch, getState) { + return Promise.try(() => { + const id = getState().admin.blockedInstances[domain].id; + return dispatch(apiCall("DELETE", `/api/v1/admin/domain_blocks/${id}`)); + }).then((removed) => { + return dispatch(admin.removeDomainBlock(removed.domain)); + }); + }; + }, + + mediaCleanup: function mediaCleanup(days) { + return function (dispatch, _getState) { + return Promise.try(() => { + return dispatch(apiCall("POST", `/api/v1/admin/media_cleanup?remote_cache_days=${days}`)); + }); + }; + }, + + fetchCustomEmoji: function fetchCustomEmoji() { + return function (dispatch, _getState) { + return Promise.try(() => { + return dispatch(apiCall("GET", "/api/v1/custom_emojis")); + }).then((emoji) => { + return dispatch(admin.setEmoji(emoji)); + }); + }; + }, + + newEmoji: function newEmoji() { + return function (dispatch, getState) { + return Promise.try(() => { + const state = getState().admin.newEmoji; + + const update = getChanges(state, { + formKeys: ["shortcode"], + fileKeys: ["image"] + }); + + return dispatch(apiCall("POST", "/api/v1/admin/custom_emojis", update, "form")); + }).then((emoji) => { + return dispatch(admin.addEmoji(emoji)); + }); + }; + } + }; + return adminAPI; +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/api/index.js b/web/source/settings-panel/lib/api/index.js new file mode 100644 index 000000000..e699011bd --- /dev/null +++ b/web/source/settings-panel/lib/api/index.js @@ -0,0 +1,185 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const { isPlainObject } = require("is-plain-object"); +const d = require("dotty"); + +const { APIError, AuthenticationError } = require("../errors"); +const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions; +const oauth = require("../../redux/reducers/oauth").actions; + +function apiCall(method, route, payload, type = "json") { + return function (dispatch, getState) { + const state = getState(); + let base = state.oauth.instance; + let auth = state.oauth.token; + console.log(method, base, route, "auth:", auth != undefined); + + return Promise.try(() => { + let url = new URL(base); + let [path, query] = route.split("?"); + url.pathname = path; + if (query != undefined) { + url.search = query; + } + let body; + + let headers = { + "Accept": "application/json", + }; + + if (payload != undefined) { + if (type == "json") { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(payload); + } else if (type == "form") { + const formData = new FormData(); + Object.entries(payload).forEach(([key, val]) => { + if (isPlainObject(val)) { + Object.entries(val).forEach(([key2, val2]) => { + if (val2 != undefined) { + formData.set(`${key}[${key2}]`, val2); + } + }); + } else { + if (val != undefined) { + formData.set(key, val); + } + } + }); + body = formData; + } + } + + if (auth != undefined) { + headers["Authorization"] = auth; + } + + return fetch(url.toString(), { + method, + headers, + body + }); + }).then((res) => { + // try parse json even with error + let json = res.json().catch((e) => { + throw new APIError(`JSON parsing error: ${e.message}`); + }); + + return Promise.all([res, json]); + }).then(([res, json]) => { + if (!res.ok) { + if (auth != undefined && (res.status == 401 || res.status == 403)) { + // stored access token is invalid + throw new AuthenticationError("401: Authentication error", {json, status: res.status}); + } else { + throw new APIError(json.error, { json }); + } + } else { + return json; + } + }); + }; +} + +function getChanges(state, keys) { + const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys; + const update = {}; + + formKeys.forEach((key) => { + let value = d.get(state, key); + if (value == undefined) { + return; + } + if (renamedKeys[key]) { + key = renamedKeys[key]; + } + d.put(update, key, value); + }); + + fileKeys.forEach((key) => { + let file = d.get(state, `${key}File`); + if (file != undefined) { + if (renamedKeys[key]) { + key = renamedKeys[key]; + } + d.put(update, key, file); + } + }); + + return update; +} + +function getCurrentUrl() { + return `${window.location.origin}${window.location.pathname}`; +} + +function fetchInstanceWithoutStore(domain) { + return function (dispatch, getState) { + return Promise.try(() => { + let lookup = getState().instances.info[domain]; + if (lookup != undefined) { + return lookup; + } + + // apiCall expects to pull the domain from state, + // but we don't want to store it there yet + // so we mock the API here with our function argument + let fakeState = { + oauth: { instance: domain } + }; + + return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState); + }).then((json) => { + if (json && json.uri) { // TODO: validate instance json more? + dispatch(setNamedInstanceInfo([domain, json])); + return json; + } + }); + }; +} + +function fetchInstance() { + return function (dispatch, _getState) { + return Promise.try(() => { + return dispatch(apiCall("GET", "/api/v1/instance")); + }).then((json) => { + if (json && json.uri) { + dispatch(setInstanceInfo(json)); + return json; + } + }); + }; +} + +let submoduleArgs = { apiCall, getCurrentUrl, getChanges }; + +module.exports = { + instance: { + fetchWithoutStore: fetchInstanceWithoutStore, + fetch: fetchInstance + }, + oauth: require("./oauth")(submoduleArgs), + user: require("./user")(submoduleArgs), + admin: require("./admin")(submoduleArgs), + apiCall, + getChanges +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/api/oauth.js b/web/source/settings-panel/lib/api/oauth.js new file mode 100644 index 000000000..76d0e9d2f --- /dev/null +++ b/web/source/settings-panel/lib/api/oauth.js @@ -0,0 +1,124 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); + +const { OAUTHError, AuthenticationError } = require("../errors"); + +const oauth = require("../../redux/reducers/oauth").actions; +const temporary = require("../../redux/reducers/temporary").actions; +const admin = require("../../redux/reducers/admin").actions; + +module.exports = function oauthAPI({ apiCall, getCurrentUrl }) { + return { + + register: function register(scopes = []) { + return function (dispatch, _getState) { + return Promise.try(() => { + return dispatch(apiCall("POST", "/api/v1/apps", { + client_name: "GoToSocial Settings", + scopes: scopes.join(" "), + redirect_uris: getCurrentUrl(), + website: getCurrentUrl() + })); + }).then((json) => { + json.scopes = scopes; + dispatch(oauth.setRegistration(json)); + }); + }; + }, + + authorize: function authorize() { + return function (dispatch, getState) { + let state = getState(); + let reg = state.oauth.registration; + let base = new URL(state.oauth.instance); + + base.pathname = "/oauth/authorize"; + base.searchParams.set("client_id", reg.client_id); + base.searchParams.set("redirect_uri", getCurrentUrl()); + base.searchParams.set("response_type", "code"); + base.searchParams.set("scope", reg.scopes.join(" ")); + + dispatch(oauth.setLoginState("callback")); + dispatch(temporary.setStatus("Redirecting to instance login...")); + + // send user to instance's login flow + window.location.assign(base.href); + }; + }, + + tokenize: function tokenize(code) { + return function (dispatch, getState) { + let reg = getState().oauth.registration; + + return Promise.try(() => { + if (reg == undefined || reg.client_id == undefined) { + throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing."); + } + + return dispatch(apiCall("POST", "/oauth/token", { + client_id: reg.client_id, + client_secret: reg.client_secret, + redirect_uri: getCurrentUrl(), + grant_type: "authorization_code", + code: code + })); + }).then((json) => { + window.history.replaceState({}, document.title, window.location.pathname); + return dispatch(oauth.login(json)); + }); + }; + }, + + checkIfAdmin: function checkIfAdmin() { + return function (dispatch, getState) { + const state = getState(); + let stored = state.oauth.isAdmin; + if (stored != undefined) { + return stored; + } + + // newer GoToSocial version will include a `role` in the Account data, check that first + // TODO: check account data for admin status + + // no role info, try fetching an admin-only route and see if we get an error + return Promise.try(() => { + return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks")); + }).then((data) => { + return Promise.all([ + dispatch(oauth.setAdmin(true)), + dispatch(admin.setBlockedInstances(data)) + ]); + }).catch(AuthenticationError, () => { + return dispatch(oauth.setAdmin(false)); + }); + }; + }, + + logout: function logout() { + return function (dispatch, _getState) { + // TODO: GoToSocial does not have a logout API route yet + + return dispatch(oauth.remove()); + }; + } + }; +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/api/user.js b/web/source/settings-panel/lib/api/user.js new file mode 100644 index 000000000..18b54bd73 --- /dev/null +++ b/web/source/settings-panel/lib/api/user.js @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); + +const user = require("../../redux/reducers/user").actions; + +module.exports = function ({ apiCall, getChanges }) { + function updateCredentials(selector, keys) { + return function (dispatch, getState) { + return Promise.try(() => { + const state = selector(getState()); + + const update = getChanges(state, keys); + + return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form")); + }).then((account) => { + return dispatch(user.setAccount(account)); + }); + }; + } + + return { + fetchAccount: function fetchAccount() { + return function (dispatch, _getState) { + return Promise.try(() => { + return dispatch(apiCall("GET", "/api/v1/accounts/verify_credentials")); + }).then((account) => { + return dispatch(user.setAccount(account)); + }); + }; + }, + + updateProfile: function updateProfile() { + const formKeys = ["display_name", "locked", "source", "custom_css", "source.note"]; + const renamedKeys = { + "source.note": "note" + }; + const fileKeys = ["header", "avatar"]; + + return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys}); + }, + + updateSettings: function updateProfile() { + const formKeys = ["source"]; + + return updateCredentials((state) => state.user.settings, {formKeys}); + } + }; +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/errors.js b/web/source/settings-panel/lib/errors.js new file mode 100644 index 000000000..c2f781cb2 --- /dev/null +++ b/web/source/settings-panel/lib/errors.js @@ -0,0 +1,27 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const createError = require("create-error"); + +module.exports = { + APIError: createError("APIError"), + OAUTHError: createError("OAUTHError"), + AuthenticationError: createError("AuthenticationError"), +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/get-views.js b/web/source/settings-panel/lib/get-views.js new file mode 100644 index 000000000..39f627435 --- /dev/null +++ b/web/source/settings-panel/lib/get-views.js @@ -0,0 +1,102 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const React = require("react"); +const Redux = require("react-redux"); +const { Link, Route, Switch, Redirect } = require("wouter"); +const { ErrorBoundary } = require("react-error-boundary"); + +const ErrorFallback = require("../components/error"); +const NavButton = require("../components/nav-button"); + +function urlSafe(str) { + return str.toLowerCase().replace(/\s+/g, "-"); +} + +module.exports = function getViews(struct) { + const sidebar = { + all: [], + admin: [], + }; + + const panelRouter = { + all: [], + admin: [], + }; + + Object.entries(struct).forEach(([name, entries]) => { + let sidebarEl = sidebar.all; + let panelRouterEl = panelRouter.all; + + if (entries.adminOnly) { + sidebarEl = sidebar.admin; + panelRouterEl = panelRouter.admin; + delete entries.adminOnly; + } + + let base = `/settings/${urlSafe(name)}`; + + let links = []; + + let firstRoute; + + Object.entries(entries).forEach(([name, ViewComponent]) => { + let url = `${base}/${urlSafe(name)}`; + + if (firstRoute == undefined) { + firstRoute = url; + } + + panelRouterEl.push(( + <Route path={`${url}/:page?`} key={url}> + <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> + {/* FIXME: implement onReset */} + <ViewComponent /> + </ErrorBoundary> + </Route> + )); + + links.push( + <NavButton key={url} href={url} name={name} /> + ); + }); + + panelRouterEl.push( + <Route key={base} path={base}> + <Redirect to={firstRoute} /> + </Route> + ); + + sidebarEl.push( + <React.Fragment key={name}> + <Link href={firstRoute}> + <a> + <h2>{name}</h2> + </a> + </Link> + <nav> + {links} + </nav> + </React.Fragment> + ); + }); + + return { sidebar, panelRouter }; +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/panel.js b/web/source/settings-panel/lib/panel.js new file mode 100644 index 000000000..df723bc74 --- /dev/null +++ b/web/source/settings-panel/lib/panel.js @@ -0,0 +1,134 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const ReactDom = require("react-dom"); + +const oauthLib = require("./oauth"); + +module.exports = function createPanel(clientName, scope, Component) { + ReactDom.render(<Panel/>, document.getElementById("root")); + + function Panel() { + const [oauth, setOauth] = React.useState(); + const [hasAuth, setAuth] = React.useState(false); + const [oauthState, setOauthState] = React.useState(localStorage.getItem("oauth")); + + React.useEffect(() => { + let state = localStorage.getItem("oauth"); + if (state != undefined) { + state = JSON.parse(state); + let restoredOauth = oauthLib(state.config, state); + Promise.try(() => { + return restoredOauth.callback(); + }).then(() => { + setAuth(true); + }); + setOauth(restoredOauth); + } + }, [setAuth, setOauth]); + + if (!hasAuth && oauth && oauth.isAuthorized()) { + setAuth(true); + } + + if (oauth && oauth.isAuthorized()) { + return <Component oauth={oauth} />; + } else if (oauthState != undefined) { + return "processing oauth..."; + } else { + return <Auth setOauth={setOauth} />; + } + } + + function Auth({setOauth}) { + const [ instance, setInstance ] = React.useState(""); + + React.useEffect(() => { + let isStillMounted = true; + // check if current domain runs an instance + let thisUrl = new URL(window.location.origin); + thisUrl.pathname = "/api/v1/instance"; + Promise.try(() => { + return fetch(thisUrl.href); + }).then((res) => { + if (res.status == 200) { + return res.json(); + } + }).then((json) => { + if (json && json.uri && isStillMounted) { + setInstance(json.uri); + } + }).catch((e) => { + console.log("error checking instance response:", e); + }); + + return () => { + // cleanup function + isStillMounted = false; + }; + }, []); + + function doAuth() { + return Promise.try(() => { + return new URL(instance); + }).catch(TypeError, () => { + return new URL(`https://${instance}`); + }).then((parsedURL) => { + let url = parsedURL.toString(); + let oauth = oauthLib({ + instance: url, + client_name: clientName, + scope: scope, + website: window.location.href + }); + setOauth(oauth); + setInstance(url); + return oauth.register().then(() => { + return oauth; + }); + }).then((oauth) => { + return oauth.authorize(); + }).catch((e) => { + console.log("error authenticating:", e); + }); + } + + function updateInstance(e) { + if (e.key == "Enter") { + doAuth(); + } else { + setInstance(e.target.value); + } + } + + return ( + <section className="login"> + <h1>OAUTH Login:</h1> + <form onSubmit={(e) => e.preventDefault()}> + <label htmlFor="instance">Instance: </label> + <input value={instance} onChange={updateInstance} id="instance"/> + <button onClick={doAuth}>Authenticate</button> + </form> + </section> + ); + } +};
\ No newline at end of file diff --git a/web/source/settings-panel/lib/submit.js b/web/source/settings-panel/lib/submit.js new file mode 100644 index 000000000..f268b5cf9 --- /dev/null +++ b/web/source/settings-panel/lib/submit.js @@ -0,0 +1,48 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); + +module.exports = function submit(func, { + setStatus, setError, + startStatus="PATCHing", successStatus="Saved!", + onSuccess, + onError +}) { + return function() { + setStatus(startStatus); + setError(""); + return Promise.try(() => { + return func(); + }).then(() => { + setStatus(successStatus); + if (onSuccess != undefined) { + return onSuccess(); + } + }).catch((e) => { + setError(e.message); + setStatus(""); + console.error(e); + if (onError != undefined) { + onError(e); + } + }); + }; +};
\ No newline at end of file diff --git a/web/source/settings-panel/redux/index.js b/web/source/settings-panel/redux/index.js new file mode 100644 index 000000000..e0dbe9b23 --- /dev/null +++ b/web/source/settings-panel/redux/index.js @@ -0,0 +1,48 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const { createStore, combineReducers, applyMiddleware } = require("redux"); +const { persistStore, persistReducer } = require("redux-persist"); +const thunk = require("redux-thunk").default; +const { composeWithDevTools } = require("redux-devtools-extension"); + +const persistConfig = { + key: "gotosocial-settings", + storage: require("redux-persist/lib/storage").default, + stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default, + whitelist: ["oauth"], + blacklist: ["temporary"] +}; + +const combinedReducers = combineReducers({ + oauth: require("./reducers/oauth").reducer, + instances: require("./reducers/instances").reducer, + temporary: require("./reducers/temporary").reducer, + user: require("./reducers/user").reducer, + admin: require("./reducers/admin").reducer, +}); + +const persistedReducer = persistReducer(persistConfig, combinedReducers); +const composedEnhancer = composeWithDevTools(applyMiddleware(thunk)); + +const store = createStore(persistedReducer, composedEnhancer); +const persistor = persistStore(store); + +module.exports = { store, persistor };
\ No newline at end of file diff --git a/web/source/settings-panel/redux/reducers/admin.js b/web/source/settings-panel/redux/reducers/admin.js new file mode 100644 index 000000000..20d3d748d --- /dev/null +++ b/web/source/settings-panel/redux/reducers/admin.js @@ -0,0 +1,131 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const { createSlice } = require("@reduxjs/toolkit"); +const defaultValue = require("default-value"); + +function sortBlocks(blocks) { + return blocks.sort((a, b) => { // alphabetical sort + return a.domain.localeCompare(b.domain); + }); +} + +function emptyBlock() { + return { + public_comment: "", + private_comment: "", + obfuscate: false + }; +} + +function emptyEmojiForm() { + return { + shortcode: "" + }; +} + +module.exports = createSlice({ + name: "admin", + initialState: { + loadedBlockedInstances: false, + blockedInstances: undefined, + bulkBlock: { + list: "", + exportType: "plain", + ...emptyBlock() + }, + newInstanceBlocks: {}, + emoji: {}, + newEmoji: emptyEmojiForm() + }, + reducers: { + setBlockedInstances: (state, { payload }) => { + state.blockedInstances = {}; + sortBlocks(payload).forEach((entry) => { + state.blockedInstances[entry.domain] = entry; + }); + state.loadedBlockedInstances = true; + }, + + newDomainBlock: (state, { payload: [domain, data] }) => { + if (data == undefined) { + data = { + new: true, + domain, + ...emptyBlock() + }; + } + state.newInstanceBlocks[domain] = data; + }, + + setDomainBlock: (state, { payload: [domain, data = {}] }) => { + state.blockedInstances[domain] = data; + }, + + removeDomainBlock: (state, {payload: domain}) => { + delete state.blockedInstances[domain]; + }, + + updateDomainBlockVal: (state, { payload: [domain, key, val] }) => { + state.newInstanceBlocks[domain][key] = val; + }, + + updateBulkBlockVal: (state, { payload: [key, val] }) => { + state.bulkBlock[key] = val; + }, + + resetBulkBlockVal: (state, { _payload }) => { + state.bulkBlock = { + list: "", + exportType: "plain", + ...emptyBlock() + }; + }, + + exportToField: (state, { _payload }) => { + state.bulkBlock.list = Object.values(state.blockedInstances).map((entry) => { + return entry.domain; + }).join("\n"); + }, + + setEmoji: (state, {payload}) => { + state.emoji = {}; + payload.forEach((emoji) => { + if (emoji.category == undefined) { + emoji.category = "Unsorted"; + } + state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []); + state.emoji[emoji.category].push(emoji); + }); + }, + + updateNewEmojiVal: (state, { payload: [key, val] }) => { + state.newEmoji[key] = val; + }, + + addEmoji: (state, {payload: emoji}) => { + if (emoji.category == undefined) { + emoji.category = "Unsorted"; + } + state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []); + state.emoji[emoji.category].push(emoji); + }, + } +});
\ No newline at end of file diff --git a/web/source/settings-panel/redux/reducers/instances.js b/web/source/settings-panel/redux/reducers/instances.js new file mode 100644 index 000000000..3ad5bb7cb --- /dev/null +++ b/web/source/settings-panel/redux/reducers/instances.js @@ -0,0 +1,42 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const {createSlice} = require("@reduxjs/toolkit"); +const d = require("dotty"); + +module.exports = createSlice({ + name: "instances", + initialState: { + info: {}, + }, + reducers: { + setNamedInstanceInfo: (state, {payload}) => { + let [key, info] = payload; + state.info[key] = info; + }, + setInstanceInfo: (state, {payload}) => { + state.current = payload; + state.adminSettings = payload; + }, + setAdminSettingsVal: (state, {payload: [key, val]}) => { + d.put(state.adminSettings, key, val); + } + } +});
\ No newline at end of file diff --git a/web/source/settings-panel/redux/reducers/oauth.js b/web/source/settings-panel/redux/reducers/oauth.js new file mode 100644 index 000000000..c332a7d06 --- /dev/null +++ b/web/source/settings-panel/redux/reducers/oauth.js @@ -0,0 +1,52 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const {createSlice} = require("@reduxjs/toolkit"); + +module.exports = createSlice({ + name: "oauth", + initialState: { + loginState: 'none', + }, + reducers: { + setInstance: (state, {payload}) => { + state.instance = payload; + }, + setRegistration: (state, {payload}) => { + state.registration = payload; + }, + setLoginState: (state, {payload}) => { + state.loginState = payload; + }, + login: (state, {payload}) => { + state.token = `${payload.token_type} ${payload.access_token}`; + state.loginState = "login"; + }, + remove: (state, {_payload}) => { + delete state.token; + delete state.registration; + delete state.isAdmin; + state.loginState = "none"; + }, + setAdmin: (state, {payload}) => { + state.isAdmin = payload; + } + } +});
\ No newline at end of file diff --git a/web/source/settings-panel/redux/reducers/temporary.js b/web/source/settings-panel/redux/reducers/temporary.js new file mode 100644 index 000000000..c887d2eee --- /dev/null +++ b/web/source/settings-panel/redux/reducers/temporary.js @@ -0,0 +1,32 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const {createSlice} = require("@reduxjs/toolkit"); + +module.exports = createSlice({ + name: "temporary", + initialState: { + }, + reducers: { + setStatus: function(state, {payload}) { + state.status = payload; + } + } +});
\ No newline at end of file diff --git a/web/source/settings-panel/redux/reducers/user.js b/web/source/settings-panel/redux/reducers/user.js new file mode 100644 index 000000000..b4463c9f9 --- /dev/null +++ b/web/source/settings-panel/redux/reducers/user.js @@ -0,0 +1,51 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const { createSlice } = require("@reduxjs/toolkit"); +const d = require("dotty"); +const defaultValue = require("default-value"); + +module.exports = createSlice({ + name: "user", + initialState: { + profile: {}, + settings: {} + }, + reducers: { + setAccount: (state, { payload }) => { + payload.source = defaultValue(payload.source, {}); + payload.source.language = defaultValue(payload.source.language.toUpperCase(), "EN"); + payload.source.status_format = defaultValue(payload.source.status_format, "plain"); + payload.source.sensitive = defaultValue(payload.source.sensitive, false); + + state.profile = payload; + // /user/settings only needs a copy of the 'source' obj + state.settings = { + source: payload.source + }; + }, + setProfileVal: (state, { payload: [key, val] }) => { + d.put(state.profile, key, val); + }, + setSettingsVal: (state, { payload: [key, val] }) => { + d.put(state.settings, key, val); + } + } +});
\ No newline at end of file diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css new file mode 100644 index 000000000..35d11fa08 --- /dev/null +++ b/web/source/settings-panel/style.css @@ -0,0 +1,498 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +body { + grid-template-rows: auto 1fr; +} + +.content { + grid-column: 1 / span 3; /* stretch entire width, to fit panel + sidebar nav */ +} + +section { + grid-column: 2; +} + +#root { + display: grid; + grid-template-columns: 1fr 90ch 1fr; + width: 100vw; + max-width: 100vw; + box-sizing: border-box; + + section.with-sidebar { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + & > div { + border-left: 0.2rem solid $border-accent; + padding-left: 0.4rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 2rem 0; + + h2 { + margin: 0; + margin-bottom: 0.5rem; + } + + &:only-child { + border-left: none; + } + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } + } + + .sidebar { + align-self: start; + justify-self: end; + background: $settings-nav-bg; + border: $boxshadow-border; + box-shadow: $boxshadow; + border-radius: $br; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + display: flex; + flex-direction: column; + min-width: 12rem; + + a { + text-decoration: none; + } + + a:first-child h2 { + border-top-left-radius: $br; + } + + h2 { + margin: 0; + padding: 0.5rem; + font-size: 0.9rem; + font-weight: bold; + text-transform: uppercase; + color: $settings-nav-header-fg; + background: $settings-nav-header-bg; + } + + nav { + display: flex; + flex-direction: column; + + a { + padding: 1rem; + text-decoration: none; + transition: 0.1s; + color: $fg; + + &:hover { + color: $settings-nav-fg-hover; + background: $settings-nav-bg-hover; + } + + &.active { + color: $settings-nav-fg-active; + background: $settings-nav-bg-active; + font-weight: bold; + text-decoration: underline; + } + + /* reserve space for bold version of the element, so .active doesn't + change container size */ + &::after { + font-weight: bold; + text-decoration: underline; + display: block; + content: attr(data-content); + height: 1px; + color: transparent; + overflow: hidden; + visibility: hidden; + } + } + } + + + nav:last-child a:last-child { + border-bottom-left-radius: $br; + border-bottom: none; + } + } +} + +.capitalize { + text-transform: capitalize; +} + +section { + margin-bottom: 1rem; +} + +input, select, textarea { + box-sizing: border-box; +} + +.error { + color: $error-fg; + background: $error-bg; + border: 0.02rem solid $error-fg; + border-radius: $br; + font-weight: bold; + padding: 0.5rem; + white-space: pre-wrap; + + a { + color: $error-link; + } + + pre { + background: $bg; + color: $fg; + padding: 1rem; + overflow: auto; + margin: 0; + } +} + +.hidden { + display: none; +} + +.messagebutton, .messagebutton > div { + display: flex; + align-items: center; + flex-wrap: wrap; + + div.padded { + margin-left: 1rem; + } + + button, .button { + white-space: nowrap; + margin-right: 1rem; + } +} + +.messagebutton > div { + button, .button { + margin-top: 1rem; + } +} + +.notImplemented { + border: 2px solid rgb(70, 79, 88); + background: repeating-linear-gradient( + -45deg, + #525c66, + #525c66 10px, + rgb(70, 79, 88) 10px, + rgb(70, 79, 88) 20px + ) !important; +} + +section.with-sidebar > div { + display: flex; + flex-direction: column; + gap: 1rem; + + input, textarea { + width: 100%; + line-height: 1.5rem; + } + + input[type=checkbox] { + justify-self: start; + width: initial; + } + + input:read-only { + border: none; + } + + input:invalid { + border-color: red; + } + + textarea { + width: 100%; + } + + h1 { + margin-bottom: 0.5rem; + } + + .moreinfolink { + font-size: 0.9em; + } + + .labelinput .border { + border-radius: 0.2rem; + border: 0.15rem solid $border_accent; + padding: 0.3rem; + display: flex; + flex-direction: column; + } + + .file-input.button { + display: inline-block; + font-size: 1rem; + font-weight: normal; + padding: 0.3rem 0.3rem; + align-self: flex-start; + margin-right: 0.2rem; + } + + .labelinput, .labelselect { + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .labelcheckbox { + display: flex; + gap: 0.4rem; + } + + .titlesave { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } +} + +.file-upload > div { + display: flex; + gap: 1rem; + + img { + height: 8rem; + border: 0.2rem solid $border-accent; + } + + img.avatar { + width: 8rem; + } + + img.header { + width: 24rem; + } +} + +.user-profile { + .overview { + display: grid; + grid-template-columns: 70% 30%; + + .basic { + margin-top: -4.5rem; + + .avatar { + height: 5rem; + width: 5rem; + } + + .displayname { + font-size: 1.3rem; + padding-top: 0; + padding-bottom: 0; + margin-top: 0.7rem; + } + } + + .files { + width: 100%; + margin: 1rem; + margin-right: 0; + display: flex; + flex-direction: column; + justify-content: center; + + div.form-field { + width: 100%; + display: flex; + + span { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0.3rem 0; + } + } + + h3 { + margin-top: 0; + margin-bottom: 0.5rem; + } + + div:first-child { + margin-bottom: 1rem; + } + + span { + font-style: italic; + } + } + } +} + +.form-field label { + font-weight: bold; +} + +.list { + display: flex; + flex-direction: column; + margin-top: 0.5rem; + max-height: 40rem; + overflow: auto; + + .entry { + display: flex; + flex-wrap: wrap; + background: $settings-entry-bg; + + &:hover { + background: $settings-entry-hover-bg; + } + } +} + +.instance-list { + .filter { + display: flex; + gap: 0.5rem; + + input { + width: auto; + flex: 1 1 auto; + } + } + + .entry { + padding: 0.3rem; + margin: 0.2rem 0; + + #domain { + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} + +.bulk h2 { + display: flex; + justify-content: space-between; +} + +.emoji-list { + background: $settings-entry-bg; + + .entry { + padding: 0.5rem; + flex-direction: column; + + .emoji-group { + display: flex; + + a { + border-radius: $br; + padding: 0.4rem; + line-height: 0; + + img { + height: 2rem; + width: 2rem; + object-fit: contain; + vertical-align: middle; + } + + &:hover { + background: $settings-entry-hover-bg; + } + } + } + + &:hover { + background: inherit; + } + } +} + +.toot { + padding-top: 0.5rem; + .contentgrid { + padding: 0 0.5rem; + } +} + +@media screen and (max-width: 100ch) { + #root { + padding: 1rem; + grid-template-columns: 100%; + grid-template-rows: auto auto; + + .sidebar { + justify-self: auto; + margin-bottom: 2rem; + } + + .sidebar, section.with-sidebar { + border-top-left-radius: $br; + border-top-right-radius: $br; + border-bottom-left-radius: $br; + border-bottom-right-radius: $br; + } + + .sidebar a:first-child h2 { + border-top-right-radius: $br; + } + } + + section { + grid-column: 1; + } + + .user-profile .overview { + grid-template-columns: 100%; + grid-template-rows: auto auto; + + .files { + margin: 0; + margin-top: 1rem; + } + } + + main section { + padding: 0.75rem; + } + + .instance-list .filter { + flex-direction: column; + } +}
\ No newline at end of file diff --git a/web/source/settings-panel/user/profile.js b/web/source/settings-panel/user/profile.js new file mode 100644 index 000000000..7cf3a7b52 --- /dev/null +++ b/web/source/settings-panel/user/profile.js @@ -0,0 +1,113 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); + +const Submit = require("../components/submit"); + +const api = require("../lib/api"); +const user = require("../redux/reducers/user").actions; +const submit = require("../lib/submit"); + +const { formFields } = require("../components/form-fields"); + +const { + TextInput, + TextArea, + Checkbox, + File +} = formFields(user.setProfileVal, (state) => state.user.profile); + +module.exports = function UserProfile() { + const dispatch = Redux.useDispatch(); + const account = Redux.useSelector(state => state.user.profile); + const instance = Redux.useSelector(state => state.instances.current); + + const allowCustomCSS = instance.configuration.accounts.allow_custom_css; + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + const saveProfile = submit( + () => dispatch(api.user.updateProfile()), + {setStatus, setError} + ); + + return ( + <div className="user-profile"> + <h1>Profile</h1> + <div className="overview"> + <div className="profile"> + <div className="headerimage"> + <img className="headerpreview" src={account.header} alt={account.header ? `header image for ${account.username}` : "None set"} /> + </div> + <div className="basic"> + <div id="profile-basic-filler2"></div> + <span className="avatar"><img className="avatarpreview" src={account.avatar} alt={account.avatar ? `avatar image for ${account.username}` : "None set"} /></span> + <div className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</div> + <div className="username"><span>@{account.username}</span></div> + </div> + </div> + <div className="files"> + <div> + <h3>Header</h3> + <File + id="header" + fileType="image/*" + /> + </div> + <div> + <h3>Avatar</h3> + <File + id="avatar" + fileType="image/*" + /> + </div> + </div> + </div> + <TextInput + id="display_name" + name="Name" + placeHolder="A GoToSocial user" + /> + <TextArea + id="source.note" + name="Bio" + placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." + /> + <Checkbox + id="locked" + name="Manually approve follow requests? " + /> + { !allowCustomCSS ? null : + <TextArea + id="custom_css" + name="Custom CSS" + className="monospace" + > + <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a> + </TextArea> + } + <Submit onClick={saveProfile} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} /> + </div> + ); +};
\ No newline at end of file diff --git a/web/source/settings-panel/user/settings.js b/web/source/settings-panel/user/settings.js new file mode 100644 index 000000000..ccb3e911d --- /dev/null +++ b/web/source/settings-panel/user/settings.js @@ -0,0 +1,140 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const Redux = require("react-redux"); + +const api = require("../lib/api"); +const user = require("../redux/reducers/user").actions; +const submit = require("../lib/submit"); + +const Languages = require("../components/languages"); +const Submit = require("../components/submit"); + +const { + Checkbox, + Select, +} = require("../components/form-fields").formFields(user.setSettingsVal, (state) => state.user.settings); + +module.exports = function UserSettings() { + const dispatch = Redux.useDispatch(); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + const updateSettings = submit( + () => dispatch(api.user.updateSettings()), + {setStatus, setError} + ); + + return ( + <> + <div className="user-settings"> + <h1>Post settings</h1> + <Select id="source.language" name="Default post language" options={ + <Languages/> + }> + </Select> + <Select id="source.privacy" name="Default post privacy" options={ + <> + <option value="private">Private / followers-only</option> + <option value="unlisted">Unlisted</option> + <option value="public">Public</option> + </> + }> + <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a> + </Select> + <Select id="source.status_format" name="Default post format" options={ + <> + <option value="plain">Plain (default)</option> + <option value="markdown">Markdown</option> + </> + }> + <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a> + </Select> + <Checkbox + id="source.sensitive" + name="Mark my posts as sensitive by default" + /> + + <Submit onClick={updateSettings} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/> + </div> + <div> + <PasswordChange/> + </div> + </> + ); +}; + +function PasswordChange() { + const dispatch = Redux.useDispatch(); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + const [oldPassword, setOldPassword] = React.useState(""); + const [newPassword, setNewPassword] = React.useState(""); + const [newPasswordConfirm, setNewPasswordConfirm] = React.useState(""); + + function changePassword() { + if (newPassword !== newPasswordConfirm) { + setError("New password and confirm new password did not match!"); + return; + } + + setStatus("PATCHing"); + setError(""); + return Promise.try(() => { + let data = { + old_password: oldPassword, + new_password: newPassword + }; + return dispatch(api.apiCall("POST", "/api/v1/user/password_change", data, "form")); + }).then(() => { + setStatus("Saved!"); + setOldPassword(""); + setNewPassword(""); + setNewPasswordConfirm(""); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } + + return ( + <> + <h1>Change password</h1> + <div className="labelinput"> + <label htmlFor="password">Current password</label> + <input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} /> + </div> + <div className="labelinput"> + <label htmlFor="new-password">New password</label> + <input name="new-password" id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> + </div> + <div className="labelinput"> + <label htmlFor="confirm-new-password">Confirm new password</label> + <input name="confirm-new-password" id="confirm-new-password" type="password" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} /> + </div> + <Submit onClick={changePassword} label="Save new password" errorMsg={errorMsg} statusMsg={statusMsg}/> + </> + ); +}
\ No newline at end of file |