diff options
Diffstat (limited to 'web/source/panels')
-rw-r--r-- | web/source/panels/admin/README.md | 21 | ||||
-rw-r--r-- | web/source/panels/admin/auth.js | 96 | ||||
-rw-r--r-- | web/source/panels/admin/blocks.js | 318 | ||||
-rw-r--r-- | web/source/panels/admin/index.js | 95 | ||||
-rw-r--r-- | web/source/panels/admin/settings.js | 175 | ||||
-rw-r--r-- | web/source/panels/admin/style.css | 141 | ||||
-rw-r--r-- | web/source/panels/user/index.js | 31 |
7 files changed, 877 insertions, 0 deletions
diff --git a/web/source/panels/admin/README.md b/web/source/panels/admin/README.md new file mode 100644 index 000000000..9a4572270 --- /dev/null +++ b/web/source/panels/admin/README.md @@ -0,0 +1,21 @@ +# GoToSocial Admin Panel + +Standalone web admin panel for [GoToSocial](https://github.com/superseriousbusiness/gotosocial). + +A public hosted instance is also available at https://gts.superseriousbusiness.org/admin/, so you can fill your own instance URL in there. + +## Installation +Build requirements: some version of Node.js with npm, +``` +git clone https://github.com/superseriousbusiness/gotosocial-admin.git && cd gotosocial-admin +npm install +node index.js +``` +All processed build output will now be in `public/`, which you can copy over to a folder in your GoToSocial installation like `web/assets/admin`, or serve elsewhere. +No further configuration is required, authentication happens through normal OAUTH flow. + +## Development +Follow the installation steps, but run `NODE_ENV=development node index.js` to start the livereloading dev server instead. + +## License, donations +[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). If you want to support my work, you can: <a href="https://liberapay.com/f0x/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a>
\ No newline at end of file diff --git a/web/source/panels/admin/auth.js b/web/source/panels/admin/auth.js new file mode 100644 index 000000000..e26ff06b9 --- /dev/null +++ b/web/source/panels/admin/auth.js @@ -0,0 +1,96 @@ +/* + 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 oauthLib = require("../../lib/oauth"); + +module.exports = 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: "GoToSocial Admin Panel", + scope: ["admin"], + 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/panels/admin/blocks.js b/web/source/panels/admin/blocks.js new file mode 100644 index 000000000..b12eb50a9 --- /dev/null +++ b/web/source/panels/admin/blocks.js @@ -0,0 +1,318 @@ +/* + 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 fileDownload = require("js-file-download"); + +function sortBlocks(blocks) { + return blocks.sort((a, b) => { // alphabetical sort + return a.domain.localeCompare(b.domain); + }); +} + +function deduplicateBlocks(blocks) { + let a = new Map(); + blocks.forEach((block) => { + a.set(block.id, block); + }); + return Array.from(a.values()); +} + +module.exports = function Blocks({oauth}) { + const [blocks, setBlocks] = React.useState([]); + const [info, setInfo] = React.useState("Fetching blocks"); + const [errorMsg, setError] = React.useState(""); + const [checked, setChecked] = React.useState(new Set()); + + React.useEffect(() => { + Promise.try(() => { + return oauth.apiRequest("/api/v1/admin/domain_blocks", undefined, undefined, "GET"); + }).then((json) => { + setInfo(""); + setError(""); + setBlocks(sortBlocks(json)); + }).catch((e) => { + setError(e.message); + setInfo(""); + }); + }, []); + + let blockList = blocks.map((block) => { + function update(e) { + let newChecked = new Set(checked.values()); + if (e.target.checked) { + newChecked.add(block.id); + } else { + newChecked.delete(block.id); + } + setChecked(newChecked); + } + + return ( + <React.Fragment key={block.id}> + <div><input type="checkbox" onChange={update} checked={checked.has(block.id)}></input></div> + <div>{block.domain}</div> + <div>{(new Date(block.created_at)).toLocaleString()}</div> + </React.Fragment> + ); + }); + + function clearChecked() { + setChecked(new Set()); + } + + function undoChecked() { + let amount = checked.size; + if(confirm(`Are you sure you want to remove ${amount} block(s)?`)) { + setInfo(""); + Promise.map(Array.from(checked.values()), (block) => { + console.log("deleting", block); + return oauth.apiRequest(`/api/v1/admin/domain_blocks/${block}`, "DELETE"); + }).then((res) => { + console.log(res); + setInfo(`Deleted ${amount} blocks: ${res.map((a) => a.domain).join(", ")}`); + }).catch((e) => { + setError(e); + }); + + let newBlocks = blocks.filter((block) => { + if (checked.size > 0 && checked.has(block.id)) { + checked.delete(block.id); + return false; + } else { + return true; + } + }); + setBlocks(newBlocks); + clearChecked(); + } + } + + return ( + <section className="blocks"> + <h1>Blocks</h1> + <div className="error accent">{errorMsg}</div> + <div>{info}</div> + <AddBlock oauth={oauth} blocks={blocks} setBlocks={setBlocks} /> + <h3>Blocks:</h3> + <div style={{display: "grid", gridTemplateColumns: "1fr auto"}}> + <span onClick={clearChecked} className="accent" style={{alignSelf: "end"}}>uncheck all</span> + <button onClick={undoChecked}>Unblock selected</button> + </div> + <div className="blocklist overflow"> + {blockList} + </div> + <BulkBlocking oauth={oauth} blocks={blocks} setBlocks={setBlocks}/> + </section> + ); +}; + +function BulkBlocking({oauth, blocks, setBlocks}) { + const [bulk, setBulk] = React.useState(""); + const [blockMap, setBlockMap] = React.useState(new Map()); + const [output, setOutput] = React.useState(); + + React.useEffect(() => { + let newBlockMap = new Map(); + blocks.forEach((block) => { + newBlockMap.set(block.domain, block); + }); + setBlockMap(newBlockMap); + }, [blocks]); + + const fileRef = React.useRef(); + + function error(e) { + setOutput(<div className="error accent">{e}</div>); + throw e; + } + + function fileUpload() { + let reader = new FileReader(); + reader.addEventListener("load", (e) => { + try { + // TODO: use validatem? + let json = JSON.parse(e.target.result); + json.forEach((block) => { + console.log("block:", block); + }); + } catch(e) { + error(e.message); + } + }); + reader.readAsText(fileRef.current.files[0]); + } + + React.useEffect(() => { + if (fileRef && fileRef.current) { + fileRef.current.addEventListener("change", fileUpload); + } + return function cleanup() { + fileRef.current.removeEventListener("change", fileUpload); + }; + }); + + function textImport() { + Promise.try(() => { + if (bulk[0] == "[") { + // assume it's json + return JSON.parse(bulk); + } else { + return bulk.split("\n").map((val) => { + return { + domain: val.trim() + }; + }); + } + }).then((domains) => { + console.log(domains); + let before = domains.length; + setOutput(`Importing ${before} domain(s)`); + domains = domains.filter(({domain}) => { + return (domain != "" && !blockMap.has(domain)); + }); + setOutput(<span>{output}<br/>{`Deduplicated ${before - domains.length}/${before} with existing blocks, adding ${domains.length} block(s)`}</span>); + if (domains.length > 0) { + let data = new FormData(); + data.append("domains", new Blob([JSON.stringify(domains)], {type: "application/json"}), "import.json"); + return oauth.apiRequest("/api/v1/admin/domain_blocks?import=true", "POST", data, "form"); + } + }).then((json) => { + console.log("bulk import result:", json); + setBlocks(sortBlocks(deduplicateBlocks([...json, ...blocks]))); + }).catch((e) => { + error(e.message); + }); + } + + function textExport() { + setBulk(blocks.reduce((str, val) => { + if (typeof str == "object") { + return str.domain; + } else { + return str + "\n" + val.domain; + } + })); + } + + function jsonExport() { + Promise.try(() => { + return oauth.apiRequest("/api/v1/admin/domain_blocks?export=true", "GET"); + }).then((json) => { + fileDownload(JSON.stringify(json), "block-export.json"); + }).catch((e) => { + error(e); + }); + } + + function textAreaUpdate(e) { + setBulk(e.target.value); + } + + return ( + <React.Fragment> + <h3>Bulk import/export</h3> + <label htmlFor="bulk">Domains, one per line:</label> + <textarea value={bulk} rows={20} onChange={textAreaUpdate}></textarea> + <div className="controls"> + <button onClick={textImport}>Import All From Field</button> + <button onClick={textExport}>Export To Field</button> + <label className="button" htmlFor="upload">Upload .json</label> + <button onClick={jsonExport}>Download .json</button> + </div> + {output} + <input type="file" id="upload" className="hidden" ref={fileRef}></input> + </React.Fragment> + ); +} + +function AddBlock({oauth, blocks, setBlocks}) { + const [domain, setDomain] = React.useState(""); + const [type, setType] = React.useState("suspend"); + const [obfuscated, setObfuscated] = React.useState(false); + const [privateDescription, setPrivateDescription] = React.useState(""); + const [publicDescription, setPublicDescription] = React.useState(""); + + function addBlock() { + console.log(`${type}ing`, domain); + Promise.try(() => { + return oauth.apiRequest("/api/v1/admin/domain_blocks", "POST", { + domain: domain, + obfuscate: obfuscated, + private_comment: privateDescription, + public_comment: publicDescription + }, "json"); + }).then((json) => { + setDomain(""); + setPrivateDescription(""); + setPublicDescription(""); + setBlocks([json, ...blocks]); + }); + } + + function onDomainChange(e) { + setDomain(e.target.value); + } + + function onTypeChange(e) { + setType(e.target.value); + } + + function onKeyDown(e) { + if (e.key == "Enter") { + addBlock(); + } + } + + return ( + <React.Fragment> + <h3>Add Block:</h3> + <div className="addblock"> + <input id="domain" placeholder="instance" onChange={onDomainChange} value={domain} onKeyDown={onKeyDown} /> + <select value={type} onChange={onTypeChange}> + <option id="suspend">Suspend</option> + <option id="silence">Silence</option> + </select> + <button onClick={addBlock}>Add</button> + <div> + <label htmlFor="private">Private description:</label><br/> + <textarea id="private" value={privateDescription} onChange={(e) => setPrivateDescription(e.target.value)}></textarea> + </div> + <div> + <label htmlFor="public">Public description:</label><br/> + <textarea id="public" value={publicDescription} onChange={(e) => setPublicDescription(e.target.value)}></textarea> + </div> + <div className="single"> + <label htmlFor="obfuscate">Obfuscate:</label> + <input id="obfuscate" type="checkbox" value={obfuscated} onChange={(e) => setObfuscated(e.target.checked)}/> + </div> + </div> + </React.Fragment> + ); +} + +// function Blocklist() { +// return ( +// <section className="blocklists"> +// <h1>Blocklists</h1> +// </section> +// ); +// }
\ No newline at end of file diff --git a/web/source/panels/admin/index.js b/web/source/panels/admin/index.js new file mode 100644 index 000000000..05ab8e583 --- /dev/null +++ b/web/source/panels/admin/index.js @@ -0,0 +1,95 @@ +/* + 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("../../lib/oauth.js"); +const Auth = require("./auth"); +const Settings = require("./settings"); +const Blocks = require("./blocks"); + +require("./style.css"); + +function App() { + 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); + } + }, []); + + if (!hasAuth && oauth && oauth.isAuthorized()) { + setAuth(true); + } + + if (oauth && oauth.isAuthorized()) { + return <AdminPanel oauth={oauth} />; + } else if (oauthState != undefined) { + return "processing oauth..."; + } else { + return <Auth setOauth={setOauth} />; + } +} + +function AdminPanel({oauth}) { + /* + Features: (issue #78) + - [ ] Instance information updating + GET /api/v1/instance PATCH /api/v1/instance + - [ ] Domain block creation, viewing, and deletion + GET /api/v1/admin/domain_blocks + POST /api/v1/admin/domain_blocks + GET /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID, DELETE /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID + - [ ] Blocklist import/export + GET /api/v1/admin/domain_blocks?export=true + POST json file as form field domains to /api/v1/admin/domain_blocks + */ + + return ( + <React.Fragment> + <Logout oauth={oauth}/> + <Settings oauth={oauth} /> + <Blocks oauth={oauth}/> + </React.Fragment> + ); +} + +function Logout({oauth}) { + return ( + <div> + <button onClick={oauth.logout}>Logout</button> + </div> + ); +} + +ReactDom.render(<App/>, document.getElementById("root"));
\ No newline at end of file diff --git a/web/source/panels/admin/settings.js b/web/source/panels/admin/settings.js new file mode 100644 index 000000000..2a10951a7 --- /dev/null +++ b/web/source/panels/admin/settings.js @@ -0,0 +1,175 @@ +/* + 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 Settings({oauth}) { + const [info, setInfo] = React.useState({}); + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState("Fetching instance info"); + + React.useEffect(() => { + Promise.try(() => { + return oauth.apiRequest("/api/v1/instance", "GET"); + }).then((json) => { + setInfo(json); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + }, []); + + function submit() { + setStatus("PATCHing"); + setError(""); + return Promise.try(() => { + let formDataInfo = new FormData(); + Object.entries(info).forEach(([key, val]) => { + if (key == "contact_account") { + key = "contact_username"; + val = val.username; + } + if (key == "email") { + key = "contact_email"; + } + if (typeof val != "object") { + formDataInfo.append(key, val); + } + }); + return oauth.apiRequest("/api/v1/instance", "PATCH", formDataInfo, "form"); + }).then((json) => { + setStatus("Config saved"); + console.log(json); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } + + return ( + <section className="info login"> + <h1>Instance Information <button onClick={submit}>Save</button></h1> + <div className="error accent"> + {errorMsg} + </div> + <div> + {statusMsg} + </div> + <form onSubmit={(e) => e.preventDefault()}> + {editableObject(info)} + </form> + </section> + ); +}; + +function editableObject(obj, path=[]) { + const readOnlyKeys = ["uri", "version", "urls_streaming_api", "stats"]; + const hiddenKeys = ["contact_account_", "urls"]; + const explicitShownKeys = ["contact_account_username"]; + const implementedKeys = "title, contact_account_username, email, short_description, description, terms, avatar, header".split(", "); + + let listing = Object.entries(obj).map(([key, val]) => { + let fullkey = [...path, key].join("_"); + + if ( + hiddenKeys.includes(fullkey) || + hiddenKeys.includes(path.join("_")+"_") // also match just parent path + ) { + if (!explicitShownKeys.includes(fullkey)) { + return null; + } + } + + if (Array.isArray(val)) { + // FIXME: handle this + } else if (typeof val == "object") { + return (<React.Fragment key={fullkey}> + {editableObject(val, [...path, key])} + </React.Fragment>); + } + + let isImplemented = ""; + if (!implementedKeys.includes(fullkey)) { + isImplemented = " notImplemented"; + } + + let isReadOnly = ( + readOnlyKeys.includes(fullkey) || + readOnlyKeys.includes(path.join("_")) || + isImplemented != "" + ); + + let label = key.replace(/_/g, " "); + if (path.length > 0) { + label = `\u00A0`.repeat(4 * path.length) + label; + } + + let inputProps; + let changeFunc; + if (val === true || val === false) { + inputProps = { + type: "checkbox", + defaultChecked: val, + disabled: isReadOnly + }; + changeFunc = (e) => e.target.checked; + } else if (val.length != 0 && !isNaN(val)) { + inputProps = { + type: "number", + defaultValue: val, + readOnly: isReadOnly + }; + changeFunc = (e) => e.target.value; + } else { + inputProps = { + type: "text", + defaultValue: val, + readOnly: isReadOnly + }; + changeFunc = (e) => e.target.value; + } + + function setRef(element) { + if (element != null) { + element.addEventListener("change", (e) => { + obj[key] = changeFunc(e); + }); + } + } + + return ( + <React.Fragment key={fullkey}> + <label htmlFor={key} className="capitalize">{label}</label> + <div className={isImplemented}> + <input className={isImplemented} ref={setRef} {...inputProps} /> + </div> + </React.Fragment> + ); + }); + return ( + <React.Fragment> + {path != "" && + <><b>{path}:</b> <span id="filler"></span></> + } + {listing} + </React.Fragment> + ); +}
\ No newline at end of file diff --git a/web/source/panels/admin/style.css b/web/source/panels/admin/style.css new file mode 100644 index 000000000..c9d2f09b4 --- /dev/null +++ b/web/source/panels/admin/style.css @@ -0,0 +1,141 @@ +/* + 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; +} + +.capitalize { + text-transform: capitalize; +} + +section { + margin-bottom: 1rem; +} + +input, select, textarea { + box-sizing: border-box; +} + +section.info { + form { + grid-template-columns: auto 1fr; + width: calc(100% - 0.35rem); + + input { + width: 100%; + line-height: 1.5rem; + } + + label, input { + padding: 0.2rem 0.5rem; + } + + input[type=checkbox] { + justify-self: start; + width: initial; + } + + input:read-only { + border: none; + } + + input:invalid { + border-color: red; + } + } + + textarea { + width: 100%; + height: 8rem; + } + + h1 { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + } +} + +section.blocks { + .overflow { + max-height: 80vh; + overflow-y: auto; + } + + .blocklist { + display: grid; + grid-template-columns: auto 1fr auto; + grid-gap: 0.35rem 0; + + div { + background: rgb(70, 79, 88); + padding: 0.2rem 0.4rem; + } + } + + .addblock { + display: grid; + grid-template-columns: 1fr auto auto; + grid-gap: 0.35rem; + + input, select { + font-size: 1.2rem; + } + + input, select, textarea { + padding: 0.5rem; + } + + div { + grid-column: 1/4; + } + + div.single input { + width: initial; + } + } + + h3 { + margin-bottom: 0; + } + + .controls { + display: flex; + gap: 0.5rem; + } +} + +.error { + font-weight: bold; +} + +.hidden { + display: none; +} + +.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; +}
\ No newline at end of file diff --git a/web/source/panels/user/index.js b/web/source/panels/user/index.js new file mode 100644 index 000000000..7adc320d8 --- /dev/null +++ b/web/source/panels/user/index.js @@ -0,0 +1,31 @@ +/* + 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"); + +// require("./style.css"); + +function App() { + return "hello world - user panel"; +} + +ReactDom.render(<App/>, document.getElementById("root"));
\ No newline at end of file |