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  | 
