Merge pull request #14 from maubot/management-frontend
Add management UI
This commit is contained in:
commit
fd5672b3dd
@ -1,9 +1,15 @@
|
||||
FROM node:10 AS frontend-builder
|
||||
|
||||
COPY ./maubot/management/frontend /frontend
|
||||
RUN cd /frontend && yarn --prod && yarn build
|
||||
|
||||
FROM alpine:3.8
|
||||
|
||||
ENV UID=1337 \
|
||||
GID=1337
|
||||
|
||||
COPY . /opt/maubot
|
||||
COPY --from=frontend-builder /frontend/build /opt/maubot/frontend
|
||||
WORKDIR /opt/maubot
|
||||
RUN apk add --no-cache \
|
||||
py3-aiohttp \
|
||||
|
@ -24,6 +24,11 @@ server:
|
||||
port: 29316
|
||||
# The base management API path.
|
||||
base_path: /_matrix/maubot/v1
|
||||
# The base path for the UI.
|
||||
ui_base_path: /_matrix/maubot
|
||||
# Override path from where to load UI resources.
|
||||
# Set to false to using pkg_resources to find the path.
|
||||
override_resource_path: /opt/maubot/frontend
|
||||
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
|
||||
appservice_base_path: /_matrix/app/v1
|
||||
# The shared secret to sign API access tokens.
|
||||
|
@ -24,6 +24,11 @@ server:
|
||||
port: 29316
|
||||
# The base management API path.
|
||||
base_path: /_matrix/maubot/v1
|
||||
# The base path for the UI.
|
||||
ui_base_path: /_matrix/maubot
|
||||
# Override path from where to load UI resources.
|
||||
# Set to false to using pkg_resources to find the path.
|
||||
override_resource_path: false
|
||||
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
|
||||
appservice_base_path: /_matrix/app/v1
|
||||
# The shared secret to sign API access tokens.
|
||||
|
@ -26,7 +26,7 @@ from .server import MaubotServer
|
||||
from .client import Client, init as init_client_class
|
||||
from .loader.zip import init as init_zip_loader
|
||||
from .instance import init as init_plugin_instance_class
|
||||
from .management.api import init as init_management
|
||||
from .management.api import init as init_management_api
|
||||
from .__meta__ import __version__
|
||||
|
||||
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
|
||||
@ -52,8 +52,9 @@ init_zip_loader(config)
|
||||
db_session = init_db(config)
|
||||
clients = init_client_class(db_session, loop)
|
||||
plugins = init_plugin_instance_class(db_session, config, loop)
|
||||
management_api = init_management(config, loop)
|
||||
server = MaubotServer(config, management_api, loop)
|
||||
management_api = init_management_api(config, loop)
|
||||
server = MaubotServer(config, loop)
|
||||
server.app.add_subapp(config["server.base_path"], management_api)
|
||||
|
||||
for plugin in plugins:
|
||||
plugin.load()
|
||||
|
@ -177,13 +177,13 @@ class Client:
|
||||
await self.stop()
|
||||
|
||||
async def update_displayname(self, displayname: str) -> None:
|
||||
if not displayname or displayname == self.displayname:
|
||||
if displayname is None or displayname == self.displayname:
|
||||
return
|
||||
self.db_instance.displayname = displayname
|
||||
await self.client.set_displayname(self.displayname)
|
||||
|
||||
async def update_avatar_url(self, avatar_url: ContentURI) -> None:
|
||||
if not avatar_url or avatar_url == self.avatar_url:
|
||||
if avatar_url is None or avatar_url == self.avatar_url:
|
||||
return
|
||||
self.db_instance.avatar_url = avatar_url
|
||||
await self.client.set_avatar_url(self.avatar_url)
|
||||
@ -198,7 +198,7 @@ class Client:
|
||||
client_session=self.http_client, log=self.log)
|
||||
mxid = await new_client.whoami()
|
||||
if mxid != self.id:
|
||||
raise ValueError("MXID mismatch")
|
||||
raise ValueError(f"MXID mismatch: {mxid}")
|
||||
new_client.store = self.db_instance
|
||||
self.stop_sync()
|
||||
self.client = new_client
|
||||
|
@ -38,6 +38,9 @@ class Config(BaseFileConfig):
|
||||
copy("server.hostname")
|
||||
copy("server.port")
|
||||
copy("server.listen")
|
||||
copy("server.base_path")
|
||||
copy("server.ui_base_path")
|
||||
copy("server.override_resource_path")
|
||||
copy("server.appservice_base_path")
|
||||
shared_secret = self["server.unshared_secret"]
|
||||
if shared_secret is None or shared_secret == "generate":
|
||||
|
@ -186,10 +186,27 @@ class PluginInstance:
|
||||
self.db_instance.primary_user = client.id
|
||||
self.client.references.remove(self)
|
||||
self.client = client
|
||||
self.client.references.add(self)
|
||||
await self.start()
|
||||
self.log.debug(f"Primary user switched to {self.client.id}")
|
||||
return True
|
||||
|
||||
async def update_type(self, type: str) -> bool:
|
||||
if not type or type == self.type:
|
||||
return True
|
||||
try:
|
||||
loader = PluginLoader.find(type)
|
||||
except KeyError:
|
||||
return False
|
||||
await self.stop()
|
||||
self.db_instance.type = loader.id
|
||||
self.loader.references.remove(self)
|
||||
self.loader = loader
|
||||
self.loader.references.add(self)
|
||||
await self.start()
|
||||
self.log.debug(f"Type switched to {self.loader.id}")
|
||||
return True
|
||||
|
||||
async def update_started(self, started: bool) -> None:
|
||||
if started is not None and started != self.started:
|
||||
await (self.start() if started else self.stop())
|
||||
|
@ -13,6 +13,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Optional
|
||||
from time import time
|
||||
import json
|
||||
|
||||
@ -39,13 +40,31 @@ def create_token(user: UserID) -> str:
|
||||
})
|
||||
|
||||
|
||||
@routes.post("/auth/ping")
|
||||
async def ping(request: web.Request) -> web.Response:
|
||||
def get_token(request: web.Request) -> str:
|
||||
token = request.headers.get("Authorization", "")
|
||||
if not token or not token.startswith("Bearer "):
|
||||
token = request.query.get("access_token", None)
|
||||
else:
|
||||
token = token[len("Bearer "):]
|
||||
return token
|
||||
|
||||
|
||||
def check_token(request: web.Request) -> Optional[web.Response]:
|
||||
token = get_token(request)
|
||||
if not token:
|
||||
return resp.no_token
|
||||
elif not is_valid_token(token):
|
||||
return resp.invalid_token
|
||||
return None
|
||||
|
||||
|
||||
@routes.post("/auth/ping")
|
||||
async def ping(request: web.Request) -> web.Response:
|
||||
token = get_token(request)
|
||||
if not token:
|
||||
return resp.no_token
|
||||
|
||||
data = verify_token(get_config()["server.unshared_secret"], token[len("Bearer "):])
|
||||
data = verify_token(get_config()["server.unshared_secret"], token)
|
||||
if not data:
|
||||
return resp.invalid_token
|
||||
user = data.get("user_id", None)
|
||||
|
@ -15,6 +15,7 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from aiohttp import web
|
||||
|
||||
from ...__meta__ import __version__
|
||||
from ...config import Config
|
||||
|
||||
routes: web.RouteTableDef = web.RouteTableDef()
|
||||
@ -28,3 +29,10 @@ def set_config(config: Config) -> None:
|
||||
|
||||
def get_config() -> Config:
|
||||
return _config
|
||||
|
||||
|
||||
@routes.get("/version")
|
||||
async def version(_: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"version": __version__
|
||||
})
|
||||
|
@ -20,7 +20,7 @@ from http import HTTPStatus
|
||||
from aiohttp import web
|
||||
|
||||
from mautrix.types import UserID, SyncToken, FilterID
|
||||
from mautrix.errors import MatrixRequestError, MatrixInvalidToken
|
||||
from mautrix.errors import MatrixRequestError, MatrixConnectionError, MatrixInvalidToken
|
||||
from mautrix.client import Client as MatrixClient
|
||||
|
||||
from ...db import DBClient
|
||||
@ -54,12 +54,14 @@ async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
|
||||
return resp.bad_client_access_token
|
||||
except MatrixRequestError:
|
||||
return resp.bad_client_access_details
|
||||
except MatrixConnectionError:
|
||||
return resp.bad_client_connection_details
|
||||
if user_id is None:
|
||||
existing_client = Client.get(mxid, None)
|
||||
if existing_client is not None:
|
||||
return resp.user_exists
|
||||
elif mxid != user_id:
|
||||
return resp.mxid_mismatch
|
||||
return resp.mxid_mismatch(mxid)
|
||||
db_instance = DBClient(id=mxid, homeserver=homeserver, access_token=access_token,
|
||||
enabled=data.get("enabled", True), next_batch=SyncToken(""),
|
||||
filter_id=FilterID(""), sync=data.get("sync", True),
|
||||
@ -81,8 +83,10 @@ async def _update_client(client: Client, data: dict) -> web.Response:
|
||||
return resp.bad_client_access_token
|
||||
except MatrixRequestError:
|
||||
return resp.bad_client_access_details
|
||||
except ValueError:
|
||||
return resp.mxid_mismatch
|
||||
except MatrixConnectionError:
|
||||
return resp.bad_client_connection_details
|
||||
except ValueError as e:
|
||||
return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
|
||||
await client.update_avatar_url(data.get("avatar_url", None))
|
||||
await client.update_displayname(data.get("displayname", None))
|
||||
await client.update_started(data.get("started", None))
|
||||
@ -127,3 +131,27 @@ async def delete_client(request: web.Request) -> web.Response:
|
||||
await client.stop()
|
||||
client.delete()
|
||||
return resp.deleted
|
||||
|
||||
|
||||
@routes.post("/client/{id}/avatar")
|
||||
async def upload_avatar(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info.get("id", None)
|
||||
client = Client.get(user_id, None)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
content = await request.read()
|
||||
return web.json_response({
|
||||
"content_uri": await client.client.upload_media(
|
||||
content, request.headers.get("Content-Type", None)),
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/client/{id}/avatar")
|
||||
async def download_avatar(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info.get("id", None)
|
||||
client = Client.get(user_id, None)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
if not client.avatar_url or client.avatar_url == "disable":
|
||||
return web.Response()
|
||||
return web.Response(body=await client.client.download_media(client.avatar_url))
|
||||
|
@ -70,6 +70,7 @@ async def _update_instance(instance: PluginInstance, data: dict) -> web.Response
|
||||
instance.update_enabled(data.get("enabled", None))
|
||||
instance.update_config(data.get("config", None))
|
||||
await instance.update_started(data.get("started", None))
|
||||
await instance.update_type(data.get("type", None))
|
||||
instance.db.commit()
|
||||
return resp.updated(instance.to_dict())
|
||||
|
||||
|
@ -19,7 +19,7 @@ import logging
|
||||
from aiohttp import web
|
||||
|
||||
from .responses import resp
|
||||
from .auth import is_valid_token
|
||||
from .auth import check_token
|
||||
|
||||
Handler = Callable[[web.Request], Awaitable[web.Response]]
|
||||
|
||||
@ -28,12 +28,7 @@ Handler = Callable[[web.Request], Awaitable[web.Response]]
|
||||
async def auth(request: web.Request, handler: Handler) -> web.Response:
|
||||
if "/auth/" in request.path:
|
||||
return await handler(request)
|
||||
token = request.headers.get("Authorization", "")
|
||||
if not token or not token.startswith("Bearer "):
|
||||
return resp.no_token
|
||||
if not is_valid_token(token[len("Bearer "):]):
|
||||
return resp.invalid_token
|
||||
return await handler(request)
|
||||
return check_token(request) or await handler(request)
|
||||
|
||||
|
||||
log = logging.getLogger("maubot.server")
|
||||
|
@ -69,6 +69,45 @@ async def reload_plugin(request: web.Request) -> web.Response:
|
||||
return resp.ok
|
||||
|
||||
|
||||
@routes.put("/plugin/{id}")
|
||||
async def put_plugin(request: web.Request) -> web.Response:
|
||||
plugin_id = request.match_info.get("id", None)
|
||||
content = await request.read()
|
||||
file = BytesIO(content)
|
||||
try:
|
||||
pid, version = ZippedPluginLoader.verify_meta(file)
|
||||
except MaubotZipImportError as e:
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
if pid != plugin_id:
|
||||
return resp.pid_mismatch
|
||||
plugin = PluginLoader.id_cache.get(plugin_id, None)
|
||||
if not plugin:
|
||||
return await upload_new_plugin(content, pid, version)
|
||||
elif isinstance(plugin, ZippedPluginLoader):
|
||||
return await upload_replacement_plugin(plugin, content, version)
|
||||
else:
|
||||
return resp.unsupported_plugin_loader
|
||||
|
||||
|
||||
@routes.post("/plugins/upload")
|
||||
async def upload_plugin(request: web.Request) -> web.Response:
|
||||
content = await request.read()
|
||||
file = BytesIO(content)
|
||||
try:
|
||||
pid, version = ZippedPluginLoader.verify_meta(file)
|
||||
except MaubotZipImportError as e:
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
plugin = PluginLoader.id_cache.get(pid, None)
|
||||
if not plugin:
|
||||
return await upload_new_plugin(content, pid, version)
|
||||
elif not request.query.get("allow_override"):
|
||||
return resp.plugin_exists
|
||||
elif isinstance(plugin, ZippedPluginLoader):
|
||||
return await upload_replacement_plugin(plugin, content, version)
|
||||
else:
|
||||
return resp.unsupported_plugin_loader
|
||||
|
||||
|
||||
async def upload_new_plugin(content: bytes, pid: str, version: str) -> web.Response:
|
||||
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
|
||||
with open(path, "wb") as p:
|
||||
@ -86,10 +125,10 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
|
||||
dirname = os.path.dirname(plugin.path)
|
||||
old_filename = os.path.basename(plugin.path)
|
||||
if plugin.version in old_filename:
|
||||
filename = old_filename.replace(plugin.version, new_version)
|
||||
if filename == old_filename:
|
||||
filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
|
||||
f"{new_version}-ts{int(time())}", old_filename)
|
||||
replacement = (new_version if plugin.version != new_version
|
||||
else f"{new_version}-ts{int(time())}")
|
||||
filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
|
||||
replacement, old_filename)
|
||||
else:
|
||||
filename = old_filename.rstrip(".mbp")
|
||||
filename = f"{filename}-v{new_version}.mbp"
|
||||
@ -110,20 +149,3 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
|
||||
await plugin.start_instances()
|
||||
ZippedPluginLoader.trash(old_path, reason="update")
|
||||
return resp.updated(plugin.to_dict())
|
||||
|
||||
|
||||
@routes.post("/plugins/upload")
|
||||
async def upload_plugin(request: web.Request) -> web.Response:
|
||||
content = await request.read()
|
||||
file = BytesIO(content)
|
||||
try:
|
||||
pid, version = ZippedPluginLoader.verify_meta(file)
|
||||
except MaubotZipImportError as e:
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
plugin = PluginLoader.id_cache.get(pid, None)
|
||||
if not plugin:
|
||||
return await upload_new_plugin(content, pid, version)
|
||||
elif isinstance(plugin, ZippedPluginLoader):
|
||||
return await upload_replacement_plugin(plugin, content, version)
|
||||
else:
|
||||
return resp.unsupported_plugin_loader
|
||||
|
@ -55,12 +55,26 @@ class _Response:
|
||||
}, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@property
|
||||
def mxid_mismatch(self) -> web.Response:
|
||||
def bad_client_connection_details(self) -> web.Response:
|
||||
return web.json_response({
|
||||
"error": "The Matrix user ID of the client and the user ID of the access token don't match",
|
||||
"error": "Could not connect to homeserver",
|
||||
"errcode": "bad_client_connection_details"
|
||||
}, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def mxid_mismatch(self, found: str) -> web.Response:
|
||||
return web.json_response({
|
||||
"error": "The Matrix user ID of the client and the user ID of the access token don't "
|
||||
f"match. Access token is for user {found}",
|
||||
"errcode": "mxid_mismatch",
|
||||
}, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@property
|
||||
def pid_mismatch(self) -> web.Response:
|
||||
return web.json_response({
|
||||
"error": "The ID in the path does not match the ID of the uploaded plugin",
|
||||
"errcode": "pid_mismatch",
|
||||
}, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@property
|
||||
def bad_auth(self) -> web.Response:
|
||||
return web.json_response({
|
||||
@ -138,6 +152,13 @@ class _Response:
|
||||
"errcode": "user_exists",
|
||||
}, status=HTTPStatus.CONFLICT)
|
||||
|
||||
@property
|
||||
def plugin_exists(self) -> web.Response:
|
||||
return web.json_response({
|
||||
"error": "A plugin with the same ID as the uploaded plugin already exists",
|
||||
"errcode": "plugin_exists"
|
||||
}, status=HTTPStatus.CONFLICT)
|
||||
|
||||
@property
|
||||
def plugin_in_use(self) -> web.Response:
|
||||
return web.json_response({
|
||||
|
@ -85,6 +85,21 @@ paths:
|
||||
summary: Upload a new plugin
|
||||
description: Upload a new plugin. If the plugin already exists, enabled instances will be restarted.
|
||||
tags: [Plugins]
|
||||
parameters:
|
||||
- name: allow_override
|
||||
in: query
|
||||
description: Set to allow overriding existing plugins
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
requestBody:
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The plugin maubot archive (.mbp)
|
||||
responses:
|
||||
200:
|
||||
description: Plugin uploaded and replaced current version successfully
|
||||
@ -102,13 +117,8 @@ paths:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
requestBody:
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The plugin maubot archive (.mbp)
|
||||
409:
|
||||
description: Plugin already exists and allow_override was not specified.
|
||||
'/plugin/{id}':
|
||||
parameters:
|
||||
- name: id
|
||||
@ -150,6 +160,39 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
put:
|
||||
operationId: put_plugin
|
||||
summary: Upload a new or replacement plugin
|
||||
description: |
|
||||
Upload a new or replacement plugin with the specified ID.
|
||||
A HTTP 400 will be returned if the ID of the uploaded plugin
|
||||
doesn't match the ID in the path. If the plugin already
|
||||
exists, enabled instances will be restarted.
|
||||
tags: [Plugins]
|
||||
requestBody:
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The plugin maubot archive (.mbp)
|
||||
responses:
|
||||
200:
|
||||
description: Plugin uploaded and replaced current version successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
201:
|
||||
description: New plugin uploaded successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/plugin/{id}/reload:
|
||||
parameters:
|
||||
- name: id
|
||||
@ -356,6 +399,45 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
'/client/{id}/avatar':
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: The Matrix user ID of the client to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
post:
|
||||
operationId: upload_avatar
|
||||
summary: Upload a profile picture for a bot
|
||||
tags: [Clients]
|
||||
requestBody:
|
||||
content:
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The avatar to upload
|
||||
image/jpeg:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The avatar to upload
|
||||
responses:
|
||||
200:
|
||||
description: The avatar was uploaded successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
content_uri:
|
||||
type: string
|
||||
description: The MXC URI of the uploaded avatar
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
components:
|
||||
responses:
|
||||
|
27
maubot/management/frontend/.sass-lint.yml
Normal file
27
maubot/management/frontend/.sass-lint.yml
Normal file
@ -0,0 +1,27 @@
|
||||
options:
|
||||
merge-default-rules: false
|
||||
formatter: html
|
||||
max-warnings: 50
|
||||
|
||||
files:
|
||||
include: 'src/style/**/*.sass'
|
||||
|
||||
rules:
|
||||
extends-before-mixins: 2
|
||||
extends-before-declarations: 2
|
||||
placeholder-in-extend: 2
|
||||
mixins-before-declarations:
|
||||
- 2
|
||||
- exclude:
|
||||
- breakpoint
|
||||
- mq
|
||||
no-warn: 1
|
||||
no-debug: 1
|
||||
hex-notation:
|
||||
- 2
|
||||
- style: uppercase
|
||||
indentation:
|
||||
- 2
|
||||
- size: 4
|
||||
property-sort-order:
|
||||
- 0
|
@ -5,8 +5,11 @@
|
||||
"dependencies": {
|
||||
"node-sass": "^4.9.4",
|
||||
"react": "^16.6.0",
|
||||
"react-ace": "^6.2.0",
|
||||
"react-dom": "^16.6.0",
|
||||
"react-scripts": "2.0.5"
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-scripts": "2.0.5",
|
||||
"react-select": "^2.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
@ -22,5 +25,10 @@
|
||||
"last 2 safari versions",
|
||||
"last 2 ios_saf versions"
|
||||
],
|
||||
"proxy": "http://localhost:29316"
|
||||
"proxy": "http://localhost:29316",
|
||||
"homepage": ".",
|
||||
"devDependencies": {
|
||||
"sass-lint": "^1.12.1",
|
||||
"sass-lint-auto-fix": "^0.15.0"
|
||||
}
|
||||
}
|
||||
|
@ -17,18 +17,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
|
||||
<link rel="stylesheet" type="text/css"
|
||||
href="https://fonts.googleapis.com/css?family=Raleway:300,400,700">
|
||||
<link rel="stylesheet" type="text/css"
|
||||
href="https://cdn.jsdelivr.net/gh/tonsky/FiraCode@1.206/distr/fira_code.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#50D367">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<title>Maubot Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
130
maubot/management/frontend/src/api.js
Normal file
130
maubot/management/frontend/src/api.js
Normal file
@ -0,0 +1,130 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
export const BASE_PATH = "/_matrix/maubot/v1"
|
||||
|
||||
function getHeaders(contentType = "application/json") {
|
||||
return {
|
||||
"Content-Type": contentType,
|
||||
"Authorization": `Bearer ${localStorage.accessToken}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultDelete(type, id) {
|
||||
const resp = await fetch(`${BASE_PATH}/${type}/${id}`, {
|
||||
headers: getHeaders(),
|
||||
method: "DELETE",
|
||||
})
|
||||
if (resp.status === 204) {
|
||||
return {
|
||||
"success": true,
|
||||
}
|
||||
}
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function defaultPut(type, entry, id = undefined) {
|
||||
const resp = await fetch(`${BASE_PATH}/${type}/${id || entry.id}`, {
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(entry),
|
||||
method: "PUT",
|
||||
})
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function defaultGet(path) {
|
||||
const resp = await fetch(`${BASE_PATH}${path}`, { headers: getHeaders() })
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const resp = await fetch(`${BASE_PATH}/auth/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
export async function ping() {
|
||||
const response = await fetch(`${BASE_PATH}/auth/ping`, {
|
||||
method: "POST",
|
||||
headers: getHeaders(),
|
||||
})
|
||||
const json = await response.json()
|
||||
if (json.username) {
|
||||
return json.username
|
||||
} else if (json.errcode === "auth_token_missing" || json.errcode === "auth_token_invalid") {
|
||||
return null
|
||||
}
|
||||
throw json
|
||||
}
|
||||
|
||||
export const getInstances = () => defaultGet("/instances")
|
||||
export const getInstance = id => defaultGet(`/instance/${id}`)
|
||||
export const putInstance = (instance, id) => defaultPut("instance", instance, id)
|
||||
export const deleteInstance = id => defaultDelete("instance", id)
|
||||
|
||||
export const getPlugins = () => defaultGet("/plugins")
|
||||
export const getPlugin = id => defaultGet(`/plugin/${id}`)
|
||||
export const deletePlugin = id => defaultDelete("plugin", id)
|
||||
|
||||
export async function uploadPlugin(data, id) {
|
||||
let resp
|
||||
if (id) {
|
||||
resp = await fetch(`${BASE_PATH}/plugin/${id}`, {
|
||||
headers: getHeaders("application/zip"),
|
||||
body: data,
|
||||
method: "PUT",
|
||||
})
|
||||
} else {
|
||||
resp = await fetch(`${BASE_PATH}/plugins/upload`, {
|
||||
headers: getHeaders("application/zip"),
|
||||
body: data,
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
export const getClients = () => defaultGet("/clients")
|
||||
export const getClient = id => defaultGet(`/clients/${id}`)
|
||||
|
||||
export async function uploadAvatar(id, data, mime) {
|
||||
const resp = await fetch(`${BASE_PATH}/client/${id}/avatar`, {
|
||||
headers: getHeaders(mime),
|
||||
body: data,
|
||||
method: "POST",
|
||||
})
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
export function getAvatarURL(id) {
|
||||
return `${BASE_PATH}/client/${id}/avatar?access_token=${localStorage.accessToken}`
|
||||
}
|
||||
|
||||
export const putClient = client => defaultPut("client", client)
|
||||
export const deleteClient = id => defaultDelete("client", id)
|
||||
|
||||
export default {
|
||||
BASE_PATH,
|
||||
login, ping,
|
||||
getInstances, getInstance, putInstance, deleteInstance,
|
||||
getPlugins, getPlugin, uploadPlugin, deletePlugin,
|
||||
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
|
||||
}
|
62
maubot/management/frontend/src/components/PreferenceTable.js
Normal file
62
maubot/management/frontend/src/components/PreferenceTable.js
Normal file
@ -0,0 +1,62 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React from "react"
|
||||
import Select from "react-select"
|
||||
import Switch from "./Switch"
|
||||
|
||||
export const PrefTable = ({ children, wrapperClass }) => {
|
||||
if (wrapperClass) {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<div className="preference-table">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="preference-table">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PrefRow = ({ name, fullWidth = false, labelFor = undefined, children }) => (
|
||||
<div className={`entry ${fullWidth ? "full-width" : ""}`}>
|
||||
<label htmlFor={labelFor}>{name}</label>
|
||||
<div className="value">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const PrefInput = ({ rowName, fullWidth = false, ...args }) => (
|
||||
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}>
|
||||
<input {...args} id={rowName}/>
|
||||
</PrefRow>
|
||||
)
|
||||
|
||||
export const PrefSwitch = ({ rowName, fullWidth = false, ...args }) => (
|
||||
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}>
|
||||
<Switch {...args} id={rowName}/>
|
||||
</PrefRow>
|
||||
)
|
||||
|
||||
export const PrefSelect = ({ rowName, fullWidth = false, ...args }) => (
|
||||
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}>
|
||||
<Select className="select" {...args} id={rowName}/>
|
||||
</PrefRow>
|
||||
)
|
||||
|
||||
export default PrefTable
|
@ -13,21 +13,16 @@
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import React from "react"
|
||||
import { Route, Redirect } from "react-router-dom"
|
||||
|
||||
class MaubotManager extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="maubot-manager">
|
||||
<header>
|
||||
const PrivateRoute = ({ component, render, authed, to = "/login", ...args }) => (
|
||||
<Route
|
||||
{...args}
|
||||
render={(props) => authed === true
|
||||
? (component ? React.createElement(component, props) : render())
|
||||
: <Redirect to={{ pathname: to }}/>}
|
||||
/>
|
||||
)
|
||||
|
||||
</header>
|
||||
<main>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MaubotManager
|
||||
export default PrivateRoute
|
11
maubot/management/frontend/src/components/Spinner.js
Normal file
11
maubot/management/frontend/src/components/Spinner.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React from "react"
|
||||
|
||||
const Spinner = (props) => (
|
||||
<div {...props} className={`spinner ${props["className"] || ""}`}>
|
||||
<svg viewBox="25 25 50 50">
|
||||
<circle cx="50" cy="50" r="20" fill="none" strokeWidth="2" strokeMiterlimit="10"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Spinner
|
57
maubot/management/frontend/src/components/Switch.js
Normal file
57
maubot/management/frontend/src/components/Switch.js
Normal file
@ -0,0 +1,57 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
|
||||
class Switch extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
active: props.active,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({
|
||||
active: nextProps.active,
|
||||
})
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
if (this.props.onToggle) {
|
||||
this.props.onToggle(!this.state.active)
|
||||
} else {
|
||||
this.setState({ active: !this.state.active })
|
||||
}
|
||||
}
|
||||
|
||||
toggleKeyboard = evt => (evt.key === " " || evt.key === "Enter") && this.toggle()
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="switch" data-active={this.state.active} onClick={this.toggle}
|
||||
tabIndex="0" onKeyPress={this.toggleKeyboard} id={this.props.id}>
|
||||
<div className="box">
|
||||
<span className="text">
|
||||
<span className="on">{this.props.onText || "On"}</span>
|
||||
<span className="off">{this.props.offText || "Off"}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Switch
|
@ -16,6 +16,6 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import "./style/index.sass"
|
||||
import MaubotManager from "./MaubotManager"
|
||||
import App from "./pages/Main"
|
||||
|
||||
ReactDOM.render(<MaubotManager/>, document.getElementById("root"))
|
||||
ReactDOM.render(<App/>, document.getElementById("root"))
|
||||
|
63
maubot/management/frontend/src/pages/Login.js
Normal file
63
maubot/management/frontend/src/pages/Login.js
Normal file
@ -0,0 +1,63 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import Spinner from "../components/Spinner"
|
||||
import api from "../api"
|
||||
|
||||
class Login extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
username: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
inputChanged = event => this.setState({ [event.target.name]: event.target.value })
|
||||
|
||||
login = async () => {
|
||||
this.setState({ loading: true })
|
||||
const resp = await api.login(this.state.username, this.state.password)
|
||||
if (resp.token) {
|
||||
await this.props.onLogin(resp.token)
|
||||
} else if (resp.error) {
|
||||
this.setState({ error: resp.error, loading: false })
|
||||
} else {
|
||||
this.setState({ error: "Unknown error", loading: false })
|
||||
console.log("Unknown error:", resp)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="login-wrapper">
|
||||
<div className={`login ${this.state.error && "errored"}`}>
|
||||
<h1>Maubot Manager</h1>
|
||||
<input type="text" placeholder="Username" value={this.state.username}
|
||||
name="username" onChange={this.inputChanged}/>
|
||||
<input type="password" placeholder="Password" value={this.state.password}
|
||||
name="password" onChange={this.inputChanged}/>
|
||||
<button onClick={this.login}>
|
||||
{this.state.loading ? <Spinner/> : "Log in"}
|
||||
</button>
|
||||
{this.state.error && <div className="error">{this.state.error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default Login
|
75
maubot/management/frontend/src/pages/Main.js
Normal file
75
maubot/management/frontend/src/pages/Main.js
Normal file
@ -0,0 +1,75 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import { HashRouter as Router, Switch } from "react-router-dom"
|
||||
import PrivateRoute from "../components/PrivateRoute"
|
||||
import Spinner from "../components/Spinner"
|
||||
import api from "../api"
|
||||
import Dashboard from "./dashboard"
|
||||
import Login from "./Login"
|
||||
|
||||
class Main extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
pinged: false,
|
||||
authed: false,
|
||||
}
|
||||
}
|
||||
|
||||
async componentWillMount() {
|
||||
if (localStorage.accessToken) {
|
||||
await this.ping()
|
||||
}
|
||||
this.setState({ pinged: true })
|
||||
}
|
||||
|
||||
async ping() {
|
||||
try {
|
||||
const username = await api.ping()
|
||||
if (username) {
|
||||
localStorage.username = username
|
||||
this.setState({ authed: true })
|
||||
} else {
|
||||
delete localStorage.accessToken
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
login = async (token) => {
|
||||
localStorage.accessToken = token
|
||||
await this.ping()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.pinged) {
|
||||
return <Spinner className="maubot-loading"/>
|
||||
}
|
||||
return <Router>
|
||||
<div className={`maubot-wrapper ${this.state.authed ? "authenticated" : ""}`}>
|
||||
<Switch>
|
||||
<PrivateRoute path="/login" render={() => <Login onLogin={this.login}/>}
|
||||
authed={!this.state.authed} to="/"/>
|
||||
<PrivateRoute path="/" component={Dashboard} authed={this.state.authed}/>
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
export default Main
|
@ -0,0 +1,68 @@
|
||||
import React, { Component } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
class BaseMainView extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = Object.assign(this.initialState, props.entry)
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState(Object.assign(this.initialState, nextProps.entry))
|
||||
}
|
||||
|
||||
delete = async () => {
|
||||
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
|
||||
return
|
||||
}
|
||||
this.setState({ deleting: true })
|
||||
const resp = await this.deleteFunc(this.state.id)
|
||||
if (resp.success) {
|
||||
this.props.history.push("/")
|
||||
this.props.onDelete()
|
||||
} else {
|
||||
this.setState({ deleting: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
throw Error("Not implemented")
|
||||
}
|
||||
|
||||
get hasInstances() {
|
||||
return this.state.instances && this.state.instances.length > 0
|
||||
}
|
||||
|
||||
get isNew() {
|
||||
return !this.props.entry
|
||||
}
|
||||
|
||||
inputChange = event => {
|
||||
if (!event.target.name) {
|
||||
return
|
||||
}
|
||||
this.setState({ [event.target.name]: event.target.value })
|
||||
}
|
||||
|
||||
async readFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsArrayBuffer(file)
|
||||
reader.onload = evt => resolve(evt.target.result)
|
||||
reader.onerror = err => reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
renderInstances = () => !this.isNew && (
|
||||
<div className="instances">
|
||||
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
|
||||
{this.state.instances.map(instance => (
|
||||
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
|
||||
{instance.id}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseMainView
|
214
maubot/management/frontend/src/pages/dashboard/Client.js
Normal file
214
maubot/management/frontend/src/pages/dashboard/Client.js
Normal file
@ -0,0 +1,214 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React from "react"
|
||||
import { NavLink, withRouter } from "react-router-dom"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import { ReactComponent as UploadButton } from "../../res/upload.svg"
|
||||
import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable"
|
||||
import Spinner from "../../components/Spinner"
|
||||
import api from "../../api"
|
||||
import BaseMainView from "./BaseMainView"
|
||||
|
||||
const ClientListEntry = ({ entry }) => {
|
||||
const classes = ["client", "entry"]
|
||||
if (!entry.enabled) {
|
||||
classes.push("disabled")
|
||||
} else if (!entry.started) {
|
||||
classes.push("stopped")
|
||||
}
|
||||
return (
|
||||
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
|
||||
<img className="avatar" src={api.getAvatarURL(entry.id)} alt=""/>
|
||||
<span className="displayname">{entry.displayname || entry.id}</span>
|
||||
<ChevronRight/>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
class Client extends BaseMainView {
|
||||
static ListEntry = ClientListEntry
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.deleteFunc = api.deleteClient
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
return {
|
||||
id: "",
|
||||
displayname: "",
|
||||
homeserver: "",
|
||||
avatar_url: "",
|
||||
access_token: "",
|
||||
sync: true,
|
||||
autojoin: true,
|
||||
enabled: true,
|
||||
started: false,
|
||||
|
||||
instances: [],
|
||||
|
||||
uploadingAvatar: false,
|
||||
saving: false,
|
||||
deleting: false,
|
||||
startingOrStopping: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
get clientInState() {
|
||||
const client = Object.assign({}, this.state)
|
||||
delete client.uploadingAvatar
|
||||
delete client.saving
|
||||
delete client.deleting
|
||||
delete client.startingOrStopping
|
||||
delete client.error
|
||||
delete client.instances
|
||||
return client
|
||||
}
|
||||
|
||||
avatarUpload = async event => {
|
||||
const file = event.target.files[0]
|
||||
this.setState({
|
||||
uploadingAvatar: true,
|
||||
})
|
||||
const data = await this.readFile(file)
|
||||
const resp = await api.uploadAvatar(this.state.id, data, file.type)
|
||||
this.setState({
|
||||
uploadingAvatar: false,
|
||||
avatar_url: resp.content_uri,
|
||||
})
|
||||
}
|
||||
|
||||
save = async () => {
|
||||
this.setState({ saving: true })
|
||||
const resp = await api.putClient(this.clientInState)
|
||||
if (resp.id) {
|
||||
if (this.isNew) {
|
||||
this.props.history.push(`/client/${resp.id}`)
|
||||
} else {
|
||||
this.setState({ saving: false, error: "" })
|
||||
}
|
||||
this.props.onChange(resp)
|
||||
} else {
|
||||
this.setState({ saving: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
startOrStop = async () => {
|
||||
this.setState({ startingOrStopping: true })
|
||||
const resp = await api.putClient({
|
||||
id: this.props.entry.id,
|
||||
started: !this.props.entry.started,
|
||||
})
|
||||
if (resp.id) {
|
||||
this.props.onChange(resp)
|
||||
this.setState({ startingOrStopping: false, error: "" })
|
||||
} else {
|
||||
this.setState({ startingOrStopping: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this.state.saving || this.state.startingOrStopping || this.state.deleting
|
||||
}
|
||||
|
||||
renderSidebar = () => !this.isNew && (
|
||||
<div className="sidebar">
|
||||
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
|
||||
${this.state.uploadingAvatar ? "uploading" : ""}`}>
|
||||
<img className="avatar" src={api.getAvatarURL(this.state.id)} alt="Avatar"/>
|
||||
<UploadButton className="upload"/>
|
||||
<input className="file-selector" type="file" accept="image/png, image/jpeg"
|
||||
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
|
||||
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
|
||||
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
|
||||
{this.state.uploadingAvatar && <Spinner/>}
|
||||
</div>
|
||||
<div className="started-container">
|
||||
<span className={`started ${this.props.entry.started}
|
||||
${this.props.entry.enabled ? "" : "disabled"}`}/>
|
||||
<span className="text">
|
||||
{this.props.entry.started ? "Started" :
|
||||
(this.props.entry.enabled ? "Stopped" : "Disabled")}
|
||||
</span>
|
||||
</div>
|
||||
{(this.props.entry.started || this.props.entry.enabled) && (
|
||||
<button className="save" onClick={this.startOrStop} disabled={this.loading}>
|
||||
{this.state.startingOrStopping ? <Spinner/>
|
||||
: (this.props.entry.started ? "Stop" : "Start")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
renderPreferences = () => (
|
||||
<PrefTable>
|
||||
<PrefInput rowName="User ID" type="text" disabled={!this.isNew} fullWidth={true}
|
||||
name={!this.isNew ? "id" : ""} value={this.state.id} className="id"
|
||||
placeholder="@fancybot:example.com" onChange={this.inputChange}/>
|
||||
<PrefInput rowName="Homeserver" type="text" name="homeserver"
|
||||
value={this.state.homeserver} placeholder="https://example.com"
|
||||
onChange={this.inputChange}/>
|
||||
<PrefInput rowName="Access token" type="text" name="access_token"
|
||||
value={this.state.access_token} onChange={this.inputChange}
|
||||
placeholder="MDAxYWxvY2F0aW9uIG1hdHJpeC5sb2NhbAowMDEzaWRlbnRpZmllc"/>
|
||||
<PrefInput rowName="Display name" type="text" name="displayname"
|
||||
value={this.state.displayname} placeholder="My fancy bot"
|
||||
onChange={this.inputChange}/>
|
||||
<PrefInput rowName="Avatar URL" type="text" name="avatar_url"
|
||||
value={this.state.avatar_url} onChange={this.inputChange}
|
||||
placeholder="mxc://example.com/mbmwyoTvPhEQPiCskcUsppko"/>
|
||||
<PrefSwitch rowName="Sync" active={this.state.sync}
|
||||
onToggle={sync => this.setState({ sync })}/>
|
||||
<PrefSwitch rowName="Autojoin" active={this.state.autojoin}
|
||||
onToggle={autojoin => this.setState({ autojoin })}/>
|
||||
<PrefSwitch rowName="Enabled" active={this.state.enabled}
|
||||
onToggle={enabled => this.setState({
|
||||
enabled,
|
||||
started: enabled && this.state.started,
|
||||
})}/>
|
||||
</PrefTable>
|
||||
)
|
||||
|
||||
renderPrefButtons = () => <>
|
||||
<div className="buttons">
|
||||
{!this.isNew && (
|
||||
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
|
||||
onClick={this.delete} disabled={this.loading || this.hasInstances}
|
||||
title={this.hasInstances ? "Can't delete client that is in use" : ""}>
|
||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
<button className="save" onClick={this.save} disabled={this.loading}>
|
||||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="error">{this.state.error}</div>
|
||||
</>
|
||||
|
||||
render() {
|
||||
return <div className="client">
|
||||
{this.renderSidebar()}
|
||||
<div className="info">
|
||||
{this.renderPreferences()}
|
||||
{this.renderPrefButtons()}
|
||||
{this.renderInstances()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Client)
|
174
maubot/management/frontend/src/pages/dashboard/Instance.js
Normal file
174
maubot/management/frontend/src/pages/dashboard/Instance.js
Normal file
@ -0,0 +1,174 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React from "react"
|
||||
import { NavLink, withRouter } from "react-router-dom"
|
||||
import AceEditor from "react-ace"
|
||||
import "brace/mode/yaml"
|
||||
import "brace/theme/github"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
|
||||
import api from "../../api"
|
||||
import Spinner from "../../components/Spinner"
|
||||
import BaseMainView from "./BaseMainView"
|
||||
|
||||
const InstanceListEntry = ({ entry }) => (
|
||||
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
|
||||
<span className="id">{entry.id}</span>
|
||||
<ChevronRight/>
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
class Instance extends BaseMainView {
|
||||
static ListEntry = InstanceListEntry
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.deleteFunc = api.deleteInstance
|
||||
this.updateClientOptions()
|
||||
}
|
||||
|
||||
get initialState() {
|
||||
return {
|
||||
id: "",
|
||||
primary_user: "",
|
||||
enabled: true,
|
||||
started: true,
|
||||
type: "",
|
||||
config: "",
|
||||
|
||||
saving: false,
|
||||
deleting: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
get instanceInState() {
|
||||
const instance = Object.assign({}, this.state)
|
||||
delete instance.saving
|
||||
delete instance.deleting
|
||||
delete instance.error
|
||||
return instance
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
super.componentWillReceiveProps(nextProps)
|
||||
this.updateClientOptions()
|
||||
}
|
||||
|
||||
clientSelectEntry = client => client && {
|
||||
id: client.id,
|
||||
value: client.id,
|
||||
label: (
|
||||
<div className="select-client">
|
||||
<img className="avatar" src={api.getAvatarURL(client.id)} alt=""/>
|
||||
<span className="displayname">{client.displayname || client.id}</span>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
updateClientOptions() {
|
||||
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
|
||||
}
|
||||
|
||||
save = async () => {
|
||||
this.setState({ saving: true })
|
||||
const resp = await api.putInstance(this.instanceInState, this.props.entry
|
||||
? this.props.entry.id : undefined)
|
||||
if (resp.id) {
|
||||
if (this.isNew) {
|
||||
this.props.history.push(`/instance/${resp.id}`)
|
||||
} else {
|
||||
if (resp.id !== this.props.entry.id) {
|
||||
this.props.history.replace(`/instance/${resp.id}`)
|
||||
}
|
||||
this.setState({ saving: false, error: "" })
|
||||
}
|
||||
this.props.onChange(resp)
|
||||
} else {
|
||||
this.setState({ saving: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
get selectedClientEntry() {
|
||||
return this.state.primary_user
|
||||
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])
|
||||
: {}
|
||||
}
|
||||
|
||||
get selectedPluginEntry() {
|
||||
return {
|
||||
id: this.state.type,
|
||||
value: this.state.type,
|
||||
label: this.state.type,
|
||||
}
|
||||
}
|
||||
|
||||
get typeOptions() {
|
||||
return Object.values(this.props.ctx.plugins).map(plugin => plugin && {
|
||||
id: plugin.id,
|
||||
value: plugin.id,
|
||||
label: plugin.id,
|
||||
})
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this.state.deleting || this.state.saving
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
return this.state.id && this.state.primary_user && this.state.type
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="instance">
|
||||
<PrefTable>
|
||||
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
|
||||
placeholder="fancybotinstance" onChange={this.inputChange}
|
||||
disabled={!this.isNew} fullWidth={true} className="id"/>
|
||||
<PrefSwitch rowName="Enabled" active={this.state.enabled}
|
||||
onToggle={enabled => this.setState({ enabled })}/>
|
||||
<PrefSwitch rowName="Running" active={this.state.started}
|
||||
onToggle={started => this.setState({ started })}/>
|
||||
<PrefSelect rowName="Primary user" options={this.clientOptions}
|
||||
isSearchable={false} value={this.selectedClientEntry}
|
||||
onChange={({ id }) => this.setState({ primary_user: id })}/>
|
||||
<PrefSelect rowName="Type" options={this.typeOptions}
|
||||
value={this.selectedPluginEntry}
|
||||
onChange={({ id }) => this.setState({ type: id })}/>
|
||||
</PrefTable>
|
||||
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
|
||||
name="config" value={this.state.config}
|
||||
editorProps={{
|
||||
fontSize: "10pt",
|
||||
$blockScrolling: true,
|
||||
}}/>
|
||||
<div className="buttons">
|
||||
{!this.isNew && (
|
||||
<button className="delete" onClick={this.delete} disabled={this.loading}>
|
||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
|
||||
onClick={this.save} disabled={this.loading || !this.isValid}>
|
||||
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="error">{this.state.error}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Instance)
|
97
maubot/management/frontend/src/pages/dashboard/Plugin.js
Normal file
97
maubot/management/frontend/src/pages/dashboard/Plugin.js
Normal file
@ -0,0 +1,97 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||
import { ReactComponent as UploadButton } from "../../res/upload.svg"
|
||||
import PrefTable, { PrefInput } from "../../components/PreferenceTable"
|
||||
import Spinner from "../../components/Spinner"
|
||||
import api from "../../api"
|
||||
import BaseMainView from "./BaseMainView"
|
||||
|
||||
const PluginListEntry = ({ entry }) => (
|
||||
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
|
||||
<span className="id">{entry.id}</span>
|
||||
<ChevronRight/>
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
|
||||
class Plugin extends BaseMainView {
|
||||
static ListEntry = PluginListEntry
|
||||
|
||||
get initialState() {
|
||||
return {
|
||||
id: "",
|
||||
version: "",
|
||||
|
||||
instances: [],
|
||||
|
||||
uploading: false,
|
||||
deleting: false,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
upload = async event => {
|
||||
const file = event.target.files[0]
|
||||
this.setState({
|
||||
uploadingAvatar: true,
|
||||
})
|
||||
const data = await this.readFile(file)
|
||||
const resp = await api.uploadPlugin(data, this.state.id)
|
||||
if (resp.id) {
|
||||
if (this.isNew) {
|
||||
this.props.history.push(`/plugin/${resp.id}`)
|
||||
} else {
|
||||
this.setState({ saving: false, error: "" })
|
||||
}
|
||||
this.props.onChange(resp)
|
||||
} else {
|
||||
this.setState({ saving: false, error: resp.error })
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="plugin">
|
||||
{!this.isNew && <PrefTable>
|
||||
<PrefInput rowName="ID" type="text" value={this.state.id} disabled={true}
|
||||
className="id"/>
|
||||
<PrefInput rowName="Version" type="text" value={this.state.version}
|
||||
disabled={true}/>
|
||||
</PrefTable>}
|
||||
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
|
||||
<UploadButton className="upload"/>
|
||||
<input className="file-selector" type="file" accept="application/zip"
|
||||
onChange={this.upload} disabled={this.state.uploading || this.state.deleting}
|
||||
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
|
||||
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
|
||||
{this.state.uploading && <Spinner/>}
|
||||
</div>
|
||||
{!this.isNew && <div className="buttons">
|
||||
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
|
||||
onClick={this.delete} disabled={this.loading || this.hasInstances}
|
||||
title={this.hasInstances ? "Can't delete plugin that is in use" : ""}>
|
||||
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||
</button>
|
||||
</div>}
|
||||
<div className="error">{this.state.error}</div>
|
||||
{!this.isNew && this.renderInstances()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default Plugin
|
163
maubot/management/frontend/src/pages/dashboard/index.js
Normal file
163
maubot/management/frontend/src/pages/dashboard/index.js
Normal file
@ -0,0 +1,163 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
import React, { Component } from "react"
|
||||
import { Route, Switch, Link, withRouter } from "react-router-dom"
|
||||
import api from "../../api"
|
||||
import { ReactComponent as Plus } from "../../res/plus.svg"
|
||||
import Instance from "./Instance"
|
||||
import Client from "./Client"
|
||||
import Plugin from "./Plugin"
|
||||
|
||||
class Dashboard extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
instances: {},
|
||||
clients: {},
|
||||
plugins: {},
|
||||
sidebarOpen: false,
|
||||
}
|
||||
window.maubot = this
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.setState({ sidebarOpen: false })
|
||||
}
|
||||
}
|
||||
|
||||
async componentWillMount() {
|
||||
const [instanceList, clientList, pluginList] = await Promise.all([
|
||||
api.getInstances(), api.getClients(), api.getPlugins()])
|
||||
const instances = {}
|
||||
for (const instance of instanceList) {
|
||||
instances[instance.id] = instance
|
||||
}
|
||||
const clients = {}
|
||||
for (const client of clientList) {
|
||||
clients[client.id] = client
|
||||
}
|
||||
const plugins = {}
|
||||
for (const plugin of pluginList) {
|
||||
plugins[plugin.id] = plugin
|
||||
}
|
||||
this.setState({ instances, clients, plugins })
|
||||
}
|
||||
|
||||
renderList(field, type) {
|
||||
return this.state[field] && Object.values(this.state[field]).map(entry =>
|
||||
React.createElement(type, { key: entry.id, entry }))
|
||||
}
|
||||
|
||||
delete(stateField, id) {
|
||||
const data = Object.assign({}, this.state[stateField])
|
||||
delete data[id]
|
||||
this.setState({ [stateField]: data })
|
||||
}
|
||||
|
||||
add(stateField, entry, oldID = undefined) {
|
||||
const data = Object.assign({}, this.state[stateField])
|
||||
if (oldID && oldID !== entry.id) {
|
||||
delete data[oldID]
|
||||
}
|
||||
data[entry.id] = entry
|
||||
this.setState({ [stateField]: data })
|
||||
}
|
||||
|
||||
renderView(field, type, id) {
|
||||
const entry = this.state[field][id]
|
||||
if (!entry) {
|
||||
return this.renderNotFound(field.slice(0, -1))
|
||||
}
|
||||
return React.createElement(type, {
|
||||
entry,
|
||||
onDelete: () => this.delete(field, id),
|
||||
onChange: newEntry => this.add(field, newEntry, id),
|
||||
ctx: this.state,
|
||||
})
|
||||
}
|
||||
|
||||
renderNotFound = (thing = "path") => (
|
||||
<div className="not-found">
|
||||
Oops! I'm afraid that {thing} couldn't be found.
|
||||
</div>
|
||||
)
|
||||
|
||||
render() {
|
||||
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
|
||||
<Link to="/" className="title">
|
||||
<img src="/favicon.png" alt=""/>
|
||||
Maubot Manager
|
||||
</Link>
|
||||
<div className="user">
|
||||
<span>{localStorage.username}</span>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar">
|
||||
<div className="instances list">
|
||||
<div className="title">
|
||||
<h2>Instances</h2>
|
||||
<Link to="/new/instance"><Plus/></Link>
|
||||
</div>
|
||||
{this.renderList("instances", Instance.ListEntry)}
|
||||
</div>
|
||||
<div className="clients list">
|
||||
<div className="title">
|
||||
<h2>Clients</h2>
|
||||
<Link to="/new/client"><Plus/></Link>
|
||||
</div>
|
||||
{this.renderList("clients", Client.ListEntry)}
|
||||
</div>
|
||||
<div className="plugins list">
|
||||
<div className="title">
|
||||
<h2>Plugins</h2>
|
||||
<Link to="/new/plugin"><Plus/></Link>
|
||||
</div>
|
||||
{this.renderList("plugins", Plugin.ListEntry)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="topbar">
|
||||
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}
|
||||
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
|
||||
<span/><span/><span/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="view">
|
||||
<Switch>
|
||||
<Route path="/" exact render={() => "Hello, World!"}/>
|
||||
<Route path="/new/instance" render={() =>
|
||||
<Instance onChange={newEntry => this.add("instances", newEntry)}
|
||||
ctx={this.state}/>}/>
|
||||
<Route path="/new/client" render={() => <Client
|
||||
onChange={newEntry => this.add("clients", newEntry)}/>}/>
|
||||
<Route path="/new/plugin" render={() => <Plugin
|
||||
onChange={newEntry => this.add("plugins", newEntry)}/>}/>
|
||||
<Route path="/instance/:id" render={({ match }) =>
|
||||
this.renderView("instances", Instance, match.params.id)}/>
|
||||
<Route path="/client/:id" render={({ match }) =>
|
||||
this.renderView("clients", Client, match.params.id)}/>
|
||||
<Route path="/plugin/:id" render={({ match }) =>
|
||||
this.renderView("plugins", Plugin, match.params.id)}/>
|
||||
<Route render={() => this.renderNotFound()}/>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Dashboard)
|
1
maubot/management/frontend/src/res/chevron-right.svg
Normal file
1
maubot/management/frontend/src/res/chevron-right.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
|
After Width: | Height: | Size: 184 B |
5
maubot/management/frontend/src/res/plus.svg
Normal file
5
maubot/management/frontend/src/res/plus.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="#000000" d="M17,13H13V17H11V13H7V11H11V7H13V11H17M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 432 B |
5
maubot/management/frontend/src/res/upload.svg
Normal file
5
maubot/management/frontend/src/res/upload.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="#000000" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 365 B |
@ -18,7 +18,6 @@ body
|
||||
margin: 0
|
||||
padding: 0
|
||||
font-size: 16px
|
||||
background-color: $background-color
|
||||
|
||||
#root
|
||||
position: fixed
|
||||
@ -27,27 +26,14 @@ body
|
||||
right: 0
|
||||
left: 0
|
||||
|
||||
//.lindeb
|
||||
> header
|
||||
position: absolute
|
||||
top: 0
|
||||
height: $header-height
|
||||
left: 0
|
||||
right: 0
|
||||
.maubot-wrapper
|
||||
position: absolute
|
||||
top: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
right: 0
|
||||
background-color: $background-dark
|
||||
|
||||
> main
|
||||
position: absolute
|
||||
top: $header-height
|
||||
bottom: 0
|
||||
left: 0
|
||||
right: 0
|
||||
|
||||
text-align: center
|
||||
|
||||
> .lindeb-content
|
||||
text-align: left
|
||||
display: inline-block
|
||||
width: 100%
|
||||
max-width: $max-width
|
||||
box-sizing: border-box
|
||||
padding: 0 1rem
|
||||
.maubot-loading
|
||||
margin-top: 10rem
|
||||
width: 10rem
|
||||
|
120
maubot/management/frontend/src/style/base/elements.sass
Normal file
120
maubot/management/frontend/src/style/base/elements.sass
Normal file
@ -0,0 +1,120 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
=button($width: null, $height: null, $padding: .375rem 1rem)
|
||||
font-family: $font-stack
|
||||
padding: $padding
|
||||
width: $width
|
||||
height: $height
|
||||
background-color: $background
|
||||
border: none
|
||||
border-radius: .25rem
|
||||
color: $text-color
|
||||
box-sizing: border-box
|
||||
font-size: 1rem
|
||||
|
||||
&.disabled-bg
|
||||
background-color: $background-dark
|
||||
|
||||
&:not(:disabled)
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
background-color: darken($background, 10%)
|
||||
|
||||
=link-button()
|
||||
display: inline-block
|
||||
text-align: center
|
||||
text-decoration: none
|
||||
|
||||
=main-color-button()
|
||||
background-color: $primary
|
||||
color: $inverted-text-color
|
||||
&:hover:not(:disabled)
|
||||
background-color: $primary-dark
|
||||
|
||||
&:disabled.disabled-bg
|
||||
background-color: $background-dark !important
|
||||
color: $text-color
|
||||
|
||||
.button
|
||||
+button
|
||||
|
||||
&.main-color
|
||||
+main-color-button
|
||||
|
||||
=button-group()
|
||||
width: 100%
|
||||
display: flex
|
||||
> button, > .button
|
||||
flex: 1
|
||||
|
||||
&:first-of-type
|
||||
margin-right: .5rem
|
||||
|
||||
&:last-of-type
|
||||
margin-left: .5rem
|
||||
|
||||
&:first-of-type:last-of-type
|
||||
margin: 0
|
||||
|
||||
=vertical-button-group()
|
||||
display: flex
|
||||
flex-direction: column
|
||||
> button, > .button
|
||||
flex: 1
|
||||
border-radius: 0
|
||||
|
||||
&:first-of-type
|
||||
border-radius: .25rem .25rem 0 0
|
||||
|
||||
&:last-of-type
|
||||
border-radius: 0 0 .25rem .25rem
|
||||
|
||||
&:first-of-type:last-of-type
|
||||
border-radius: .25rem
|
||||
|
||||
=input($width: null, $height: null, $vertical-padding: .375rem, $horizontal-padding: 1rem, $font-size: 1rem)
|
||||
font-family: $font-stack
|
||||
border: 1px solid $border-color
|
||||
background-color: $background
|
||||
color: $text-color
|
||||
width: $width
|
||||
height: $height
|
||||
box-sizing: border-box
|
||||
border-radius: .25rem
|
||||
padding: $vertical-padding $horizontal-padding
|
||||
font-size: $font-size
|
||||
resize: vertical
|
||||
|
||||
&:hover, &:focus
|
||||
border-color: $primary
|
||||
|
||||
&:focus
|
||||
border-width: 2px
|
||||
padding: calc(#{$vertical-padding} - 1px) calc(#{$horizontal-padding} - 1px)
|
||||
|
||||
.input, .textarea
|
||||
+input
|
||||
|
||||
input
|
||||
font-family: $font-stack
|
||||
|
||||
=notification($border: $error-dark, $background: transparentize($error-light, 0.5))
|
||||
padding: 1rem
|
||||
border-radius: .25rem
|
||||
border: 2px solid $border
|
||||
background-color: $background
|
@ -13,16 +13,20 @@
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
$main-color: darken(#50D367, 10%)
|
||||
$dark-color: darken($main-color, 10%)
|
||||
$light-color: lighten($main-color, 10%)
|
||||
$alt-color: darken(#47B9D7, 10%)
|
||||
$dark-alt-color: darken($alt-color, 10%)
|
||||
$border-color: #CCC
|
||||
$error-color: #D35067
|
||||
|
||||
$primary: #00C853
|
||||
$primary-dark: #009624
|
||||
$primary-light: #5EFC82
|
||||
$secondary: #00B8D4
|
||||
$secondary-dark: #0088A3
|
||||
$secondary-light: #62EBFF
|
||||
$error: #B71C1C
|
||||
$error-dark: #7F0000
|
||||
$error-light: #F05545
|
||||
|
||||
$border-color: #DDD
|
||||
$text-color: #212121
|
||||
$background-color: #FAFAFA
|
||||
$inverted-text-color: $background-color
|
||||
$font-stack: sans-serif
|
||||
$max-width: 42.5rem
|
||||
$header-height: 3.5rem
|
||||
$background: #FAFAFA
|
||||
$background-dark: #E7E7E7
|
||||
$inverted-text-color: $background
|
||||
$font-stack: Raleway, sans-serif
|
||||
|
@ -13,5 +13,17 @@
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
@import lib/spinner
|
||||
|
||||
@import base/vars
|
||||
@import base/body
|
||||
@import base/elements
|
||||
|
||||
@import lib/preferencetable
|
||||
@import lib/switch
|
||||
|
||||
@import pages/mixins/upload-container
|
||||
@import pages/mixins/instancelist
|
||||
|
||||
@import pages/login
|
||||
@import pages/dashboard
|
||||
|
@ -0,0 +1,79 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
.preference-table
|
||||
display: flex
|
||||
|
||||
width: 100%
|
||||
|
||||
flex-wrap: wrap
|
||||
|
||||
> .entry
|
||||
display: block
|
||||
|
||||
@media screen and (max-width: 55rem)
|
||||
width: calc(100% - 1rem)
|
||||
width: calc(50% - 1rem)
|
||||
margin: .5rem
|
||||
|
||||
&.full-width
|
||||
width: 100%
|
||||
|
||||
> label, > .value
|
||||
display: block
|
||||
width: 100%
|
||||
|
||||
> label
|
||||
font-size: 0.875rem
|
||||
padding-bottom: .25rem
|
||||
font-weight: lighter
|
||||
|
||||
> .value
|
||||
> .switch
|
||||
width: auto
|
||||
height: 2rem
|
||||
|
||||
> .select
|
||||
height: 2.5rem
|
||||
box-sizing: border-box
|
||||
|
||||
> input
|
||||
border: none
|
||||
height: 2rem
|
||||
width: 100%
|
||||
color: $text-color
|
||||
|
||||
box-sizing: border-box
|
||||
|
||||
padding: .375rem 0
|
||||
background-color: $background
|
||||
|
||||
font-size: 1rem
|
||||
|
||||
border-bottom: 1px solid $background
|
||||
|
||||
&.id:disabled
|
||||
font-family: "Fira Code", monospace
|
||||
font-weight: bold
|
||||
|
||||
&:not(:disabled)
|
||||
border-bottom: 1px dotted $primary
|
||||
|
||||
&:hover
|
||||
border-bottom: 1px solid $primary
|
||||
|
||||
&:focus
|
||||
border-bottom: 2px solid $primary
|
65
maubot/management/frontend/src/style/lib/spinner.sass
Normal file
65
maubot/management/frontend/src/style/lib/spinner.sass
Normal file
@ -0,0 +1,65 @@
|
||||
$green: #008744
|
||||
$blue: #0057e7
|
||||
$red: #d62d20
|
||||
$yellow: #ffa700
|
||||
|
||||
.spinner
|
||||
position: relative
|
||||
margin: 0 auto
|
||||
width: 5rem
|
||||
|
||||
&:before
|
||||
content: ""
|
||||
display: block
|
||||
padding-top: 100%
|
||||
|
||||
svg
|
||||
animation: rotate 2s linear infinite
|
||||
height: 100%
|
||||
transform-origin: center center
|
||||
width: 100%
|
||||
position: absolute
|
||||
top: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
right: 0
|
||||
margin: auto
|
||||
|
||||
circle
|
||||
stroke-dasharray: 1, 200
|
||||
stroke-dashoffset: 0
|
||||
animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite
|
||||
stroke-linecap: round
|
||||
|
||||
=white-spinner()
|
||||
circle
|
||||
stroke: white !important
|
||||
|
||||
=thick-spinner($thickness: 5)
|
||||
svg > circle
|
||||
stroke-width: $thickness
|
||||
|
||||
@keyframes rotate
|
||||
100%
|
||||
transform: rotate(360deg)
|
||||
|
||||
@keyframes dash
|
||||
0%
|
||||
stroke-dasharray: 1, 200
|
||||
stroke-dashoffset: 0
|
||||
50%
|
||||
stroke-dasharray: 89, 200
|
||||
stroke-dashoffset: -35px
|
||||
100%
|
||||
stroke-dasharray: 89, 200
|
||||
stroke-dashoffset: -124px
|
||||
|
||||
@keyframes color
|
||||
100%, 0%
|
||||
stroke: $red
|
||||
40%
|
||||
stroke: $blue
|
||||
66%
|
||||
stroke: $green
|
||||
80%, 90%
|
||||
stroke: $yellow
|
77
maubot/management/frontend/src/style/lib/switch.sass
Normal file
77
maubot/management/frontend/src/style/lib/switch.sass
Normal file
@ -0,0 +1,77 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
.switch
|
||||
display: flex
|
||||
|
||||
width: 100%
|
||||
height: 2rem
|
||||
|
||||
cursor: pointer
|
||||
|
||||
border: 1px solid $error-light
|
||||
border-radius: .25rem
|
||||
background-color: $background
|
||||
|
||||
box-sizing: border-box
|
||||
|
||||
> .box
|
||||
display: flex
|
||||
box-sizing: border-box
|
||||
width: 50%
|
||||
height: 100%
|
||||
|
||||
transition: .5s
|
||||
text-align: center
|
||||
|
||||
border-radius: .15rem 0 0 .15rem
|
||||
background-color: $error-light
|
||||
color: $inverted-text-color
|
||||
|
||||
align-items: center
|
||||
|
||||
> .text
|
||||
box-sizing: border-box
|
||||
width: 100%
|
||||
|
||||
text-align: center
|
||||
vertical-align: middle
|
||||
|
||||
color: $inverted-text-color
|
||||
font-size: 1rem
|
||||
|
||||
user-select: none
|
||||
|
||||
.on
|
||||
display: none
|
||||
|
||||
.off
|
||||
display: inline
|
||||
|
||||
|
||||
&[data-active=true]
|
||||
border: 1px solid $primary
|
||||
> .box
|
||||
background-color: $primary
|
||||
transform: translateX(100%)
|
||||
|
||||
border-radius: 0 .15rem .15rem 0
|
||||
|
||||
.on
|
||||
display: inline
|
||||
|
||||
.off
|
||||
display: none
|
@ -0,0 +1,62 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
> div.avatar-container
|
||||
+upload-box
|
||||
|
||||
width: 8rem
|
||||
height: 8rem
|
||||
border-radius: 50%
|
||||
|
||||
@media screen and (max-width: 40rem)
|
||||
margin: 0 auto 1rem
|
||||
|
||||
> img.avatar
|
||||
position: absolute
|
||||
display: block
|
||||
max-width: 8rem
|
||||
max-height: 8rem
|
||||
user-select: none
|
||||
|
||||
> svg.upload
|
||||
visibility: hidden
|
||||
|
||||
width: 6rem
|
||||
height: 6rem
|
||||
|
||||
> input.file-selector
|
||||
width: 8rem
|
||||
height: 8rem
|
||||
|
||||
&:not(.uploading)
|
||||
&:hover, &.drag
|
||||
> img.avatar
|
||||
opacity: .25
|
||||
|
||||
> svg.upload
|
||||
visibility: visible
|
||||
|
||||
&.no-avatar
|
||||
> img.avatar
|
||||
visibility: hidden
|
||||
|
||||
> svg.upload
|
||||
visibility: visible
|
||||
opacity: .5
|
||||
|
||||
&.uploading
|
||||
> img.avatar
|
||||
opacity: .25
|
44
maubot/management/frontend/src/style/pages/client/index.sass
Normal file
44
maubot/management/frontend/src/style/pages/client/index.sass
Normal file
@ -0,0 +1,44 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
> div.client
|
||||
display: flex
|
||||
|
||||
> div.sidebar
|
||||
vertical-align: top
|
||||
text-align: center
|
||||
width: 8rem
|
||||
margin-right: 1rem
|
||||
|
||||
> div
|
||||
margin-bottom: 1rem
|
||||
|
||||
@import avatar
|
||||
@import started
|
||||
|
||||
> div.info
|
||||
vertical-align: top
|
||||
flex: 1
|
||||
|
||||
> div.instances
|
||||
+instancelist
|
||||
|
||||
@media screen and (max-width: 40rem)
|
||||
flex-wrap: wrap
|
||||
|
||||
> div.sidebar, > div.info
|
||||
width: 100%
|
||||
margin-right: 0
|
@ -0,0 +1,41 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
> div.started-container
|
||||
display: inline-flex
|
||||
|
||||
> span.started
|
||||
display: inline-block
|
||||
height: 0
|
||||
width: 0
|
||||
border-radius: 50%
|
||||
margin: .5rem
|
||||
|
||||
&.true
|
||||
background-color: $primary
|
||||
box-shadow: 0 0 .75rem .75rem $primary
|
||||
|
||||
&.false
|
||||
background-color: $error-light
|
||||
box-shadow: 0 0 .75rem .75rem $error-light
|
||||
|
||||
&.disabled
|
||||
background-color: $border-color
|
||||
box-shadow: 0 0 .75rem .75rem $border-color
|
||||
|
||||
> span.text
|
||||
display: inline-block
|
||||
margin-left: 1rem
|
@ -0,0 +1,26 @@
|
||||
.dashboard {
|
||||
grid-template:
|
||||
[row1-start] "title main" 3.5rem [row1-end]
|
||||
[row2-start] "user main" 2.5rem [row2-end]
|
||||
[row3-start] "sidebar main" auto [row3-end]
|
||||
/ 15rem auto;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 35rem) {
|
||||
.dashboard {
|
||||
grid-template:
|
||||
[row1-start] "topbar" 3.5rem [row1-end]
|
||||
[row2-start] "main" auto [row2-end]
|
||||
/ auto;
|
||||
}
|
||||
|
||||
.dashboard.sidebar-open {
|
||||
grid-template:
|
||||
[row1-start] "title topbar" 3.5rem [row1-end]
|
||||
[row2-start] "user main" 2.5rem [row2-end]
|
||||
[row3-start] "sidebar main" auto [row3-end]
|
||||
/ 15rem 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
120
maubot/management/frontend/src/style/pages/dashboard.sass
Normal file
120
maubot/management/frontend/src/style/pages/dashboard.sass
Normal file
@ -0,0 +1,120 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
@import "dashboard-grid"
|
||||
|
||||
.dashboard
|
||||
display: grid
|
||||
height: 100%
|
||||
max-width: 60rem
|
||||
margin: auto
|
||||
box-shadow: 0 .5rem .5rem rgba(0, 0, 0, 0.5)
|
||||
background-color: $background
|
||||
|
||||
> a.title
|
||||
grid-area: title
|
||||
background-color: white
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
font-size: 1.35rem
|
||||
font-weight: bold
|
||||
|
||||
color: $text-color
|
||||
text-decoration: none
|
||||
|
||||
> img
|
||||
max-width: 2rem
|
||||
margin-right: .5rem
|
||||
|
||||
> div.user
|
||||
grid-area: user
|
||||
background-color: white
|
||||
border-bottom: 1px solid $border-color
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
span
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
background-color: $primary
|
||||
color: $inverted-text-color
|
||||
margin: .375rem .5rem
|
||||
width: 100%
|
||||
height: calc(100% - .375rem)
|
||||
box-sizing: border-box
|
||||
border-radius: .25rem
|
||||
|
||||
@import sidebar
|
||||
@import topbar
|
||||
|
||||
@media screen and (max-width: 35rem)
|
||||
&:not(.sidebar-open)
|
||||
> nav.sidebar, > a.title, > div.user
|
||||
display: none !important
|
||||
|
||||
> main.view
|
||||
grid-area: main
|
||||
border-left: 1px solid $border-color
|
||||
|
||||
overflow-y: auto
|
||||
|
||||
@import client/index
|
||||
@import instance
|
||||
@import plugin
|
||||
|
||||
> .not-found
|
||||
text-align: center
|
||||
margin-top: 5rem
|
||||
font-size: 1.5rem
|
||||
|
||||
> div:not(.not-found)
|
||||
margin: 2rem 4rem
|
||||
|
||||
@media screen and (max-width: 50rem)
|
||||
margin: 2rem 1rem
|
||||
|
||||
div.buttons
|
||||
+button-group
|
||||
display: flex
|
||||
margin: 1rem .5rem
|
||||
width: calc(100% - 1rem)
|
||||
|
||||
div.error
|
||||
+notification($error)
|
||||
margin: 1rem .5rem
|
||||
|
||||
&:empty
|
||||
display: none
|
||||
|
||||
button.delete
|
||||
background-color: $error-light !important
|
||||
|
||||
&:hover
|
||||
background-color: $error !important
|
||||
|
||||
button.save, button.delete
|
||||
+button
|
||||
+main-color-button
|
||||
width: 100%
|
||||
height: 2.5rem
|
||||
padding: 0
|
||||
|
||||
> .spinner
|
||||
+thick-spinner
|
||||
+white-spinner
|
||||
width: 2rem
|
35
maubot/management/frontend/src/style/pages/instance.sass
Normal file
35
maubot/management/frontend/src/style/pages/instance.sass
Normal file
@ -0,0 +1,35 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
> div.instance
|
||||
> div.preference-table
|
||||
.select-client
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
img.avatar
|
||||
max-height: 1.375rem
|
||||
border-radius: 50%
|
||||
margin-right: .5rem
|
||||
|
||||
> div.ace_editor
|
||||
z-index: 0
|
||||
height: 15rem !important
|
||||
width: calc(100% - 1rem) !important
|
||||
font-size: 12px
|
||||
font-family: "Fira Code", monospace
|
||||
|
||||
margin: .75rem .5rem 1.5rem
|
62
maubot/management/frontend/src/style/pages/login.sass
Normal file
62
maubot/management/frontend/src/style/pages/login.sass
Normal file
@ -0,0 +1,62 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
.maubot-wrapper:not(.authenticated)
|
||||
background-color: $primary
|
||||
|
||||
text-align: center
|
||||
|
||||
.login
|
||||
width: 25rem
|
||||
height: 23rem
|
||||
display: inline-block
|
||||
box-sizing: border-box
|
||||
background-color: white
|
||||
border-radius: .25rem
|
||||
margin-top: 3rem
|
||||
|
||||
@media screen and (max-width: 27rem)
|
||||
margin: 3rem 1rem 0
|
||||
width: calc(100% - 2rem)
|
||||
|
||||
h1
|
||||
color: $primary
|
||||
margin: 3rem 0
|
||||
|
||||
input, button
|
||||
margin: .5rem 2.5rem
|
||||
height: 3rem
|
||||
width: calc(100% - 5rem)
|
||||
box-sizing: border-box
|
||||
|
||||
input
|
||||
+input
|
||||
|
||||
button
|
||||
+button($width: calc(100% - 5rem), $height: 3rem, $padding: 0)
|
||||
+main-color-button
|
||||
|
||||
.spinner
|
||||
+white-spinner
|
||||
+thick-spinner
|
||||
width: 2rem
|
||||
|
||||
&.errored
|
||||
height: 26.5rem
|
||||
|
||||
.error
|
||||
+notification($error)
|
||||
margin: .5rem 2.5rem
|
@ -0,0 +1,40 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
=instancelist()
|
||||
margin: 1rem 0
|
||||
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
> h3
|
||||
margin: .5rem
|
||||
width: 100%
|
||||
|
||||
> a.instance
|
||||
display: block
|
||||
width: calc(50% - 1rem)
|
||||
padding: .375rem .5rem
|
||||
margin: .5rem
|
||||
background-color: white
|
||||
border-radius: .25rem
|
||||
color: $text-color
|
||||
text-decoration: none
|
||||
box-sizing: border-box
|
||||
border: 1px solid $primary
|
||||
|
||||
&:hover
|
||||
background-color: $primary
|
@ -0,0 +1,43 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
=upload-box()
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
> svg.upload
|
||||
position: absolute
|
||||
display: block
|
||||
|
||||
padding: 1rem
|
||||
user-select: none
|
||||
|
||||
|
||||
> input.file-selector
|
||||
position: absolute
|
||||
user-select: none
|
||||
opacity: 0
|
||||
|
||||
> div.spinner
|
||||
+thick-spinner
|
||||
|
||||
&:not(.uploading)
|
||||
> input.file-selector
|
||||
cursor: pointer
|
53
maubot/management/frontend/src/style/pages/plugin.sass
Normal file
53
maubot/management/frontend/src/style/pages/plugin.sass
Normal file
@ -0,0 +1,53 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
> .plugin
|
||||
> .upload-box
|
||||
+upload-box
|
||||
|
||||
width: calc(100% - 1rem)
|
||||
height: 10rem
|
||||
margin: .5rem
|
||||
border-radius: .5rem
|
||||
box-sizing: border-box
|
||||
|
||||
border: .25rem dotted $primary
|
||||
|
||||
> svg.upload
|
||||
width: 8rem
|
||||
height: 8rem
|
||||
|
||||
opacity: .5
|
||||
|
||||
> input.file-selector
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
&:not(.uploading):hover, &:not(.uploading).drag
|
||||
border: .25rem solid $primary
|
||||
background-color: $primary-light
|
||||
|
||||
> svg.upload
|
||||
opacity: 1
|
||||
|
||||
&.uploading
|
||||
> svg.upload
|
||||
visibility: hidden
|
||||
> input.file-selector
|
||||
cursor: default
|
||||
|
||||
> div.instances
|
||||
+instancelist
|
69
maubot/management/frontend/src/style/pages/sidebar.sass
Normal file
69
maubot/management/frontend/src/style/pages/sidebar.sass
Normal file
@ -0,0 +1,69 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
> nav.sidebar
|
||||
grid-area: sidebar
|
||||
background-color: white
|
||||
|
||||
padding: .5rem
|
||||
|
||||
overflow-y: auto
|
||||
|
||||
div.list
|
||||
&:not(:last-of-type)
|
||||
margin-bottom: 1.5rem
|
||||
|
||||
div.title
|
||||
h2
|
||||
display: inline-block
|
||||
margin: 0 0 .25rem 0
|
||||
font-size: 1.25rem
|
||||
|
||||
a
|
||||
display: inline-block
|
||||
float: right
|
||||
|
||||
a.entry
|
||||
display: block
|
||||
color: $text-color
|
||||
text-decoration: none
|
||||
padding: .25rem
|
||||
border-radius: .25rem
|
||||
height: 2rem
|
||||
box-sizing: border-box
|
||||
|
||||
&:not(:hover) > svg
|
||||
display: none
|
||||
|
||||
> svg
|
||||
float: right
|
||||
|
||||
&:hover
|
||||
background-color: $primary-light
|
||||
|
||||
&.active
|
||||
background-color: $primary
|
||||
color: white
|
||||
|
||||
&.client
|
||||
img.avatar
|
||||
max-height: 1.5rem
|
||||
border-radius: 100%
|
||||
vertical-align: middle
|
||||
|
||||
span.displayname, span.id
|
||||
margin-left: .25rem
|
||||
vertical-align: middle
|
74
maubot/management/frontend/src/style/pages/topbar.sass
Normal file
74
maubot/management/frontend/src/style/pages/topbar.sass
Normal file
@ -0,0 +1,74 @@
|
||||
// maubot - A plugin-based Matrix bot system.
|
||||
// Copyright (C) 2018 Tulir Asokan
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
.topbar
|
||||
background-color: $primary
|
||||
|
||||
display: flex
|
||||
justify-items: center
|
||||
align-items: center
|
||||
padding: 0 .75rem
|
||||
|
||||
@media screen and (min-width: calc(35rem + 1px))
|
||||
display: none
|
||||
|
||||
// Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license)
|
||||
// https://codepen.io/erikterwan/pen/EVzeRP
|
||||
|
||||
.hamburger
|
||||
display: block
|
||||
user-select: none
|
||||
cursor: pointer
|
||||
|
||||
> span
|
||||
display: block
|
||||
width: 29px
|
||||
height: 4px
|
||||
margin-bottom: 5px
|
||||
position: relative
|
||||
|
||||
background: white
|
||||
border-radius: 3px
|
||||
|
||||
z-index: 1
|
||||
|
||||
transform-origin: 4px 0
|
||||
|
||||
//transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), opacity 0.55s ease
|
||||
|
||||
&:nth-of-type(1)
|
||||
transform-origin: 0 0
|
||||
|
||||
&:nth-of-type(3)
|
||||
transform-origin: 0 100%
|
||||
|
||||
transform: translateY(2px)
|
||||
|
||||
&.active
|
||||
transform: translateX(1px) translateY(4px)
|
||||
|
||||
&.active > span
|
||||
opacity: 1
|
||||
|
||||
&:nth-of-type(1)
|
||||
transform: rotate(45deg) translate(-2px, -1px)
|
||||
|
||||
&:nth-of-type(2)
|
||||
opacity: 0
|
||||
transform: rotate(0deg) scale(0.2, 0.2)
|
||||
|
||||
&:nth-of-type(3)
|
||||
transform: rotate(-45deg) translate(0, -1px)
|
File diff suppressed because it is too large
Load Diff
@ -13,33 +13,78 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from aiohttp import web
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.abc import AbstractAccessLogger
|
||||
import pkg_resources
|
||||
|
||||
from mautrix.api import PathBuilder, Method
|
||||
|
||||
from .config import Config
|
||||
from .__meta__ import __version__
|
||||
|
||||
|
||||
class AccessLogger(AbstractAccessLogger):
|
||||
def log(self, request: web.Request, response: web.Response, time: int):
|
||||
self.logger.info(f'{request.remote} "{request.method} {request.path} '
|
||||
f'{response.status} {response.body_length} '
|
||||
f'in {round(time, 4)}s"')
|
||||
|
||||
|
||||
class MaubotServer:
|
||||
log: logging.Logger = logging.getLogger("maubot.server")
|
||||
|
||||
def __init__(self, config: Config, management: web.Application,
|
||||
loop: asyncio.AbstractEventLoop) -> None:
|
||||
def __init__(self, config: Config, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self.loop = loop or asyncio.get_event_loop()
|
||||
self.app = web.Application(loop=self.loop)
|
||||
self.config = config
|
||||
|
||||
path = PathBuilder(config["server.base_path"])
|
||||
self.add_route(Method.GET, path.version, self.version)
|
||||
self.app.add_subapp(config["server.base_path"], management)
|
||||
|
||||
as_path = PathBuilder(config["server.appservice_base_path"])
|
||||
self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
self.setup_management_ui()
|
||||
|
||||
self.runner = web.AppRunner(self.app, access_log_class=AccessLogger)
|
||||
|
||||
def setup_management_ui(self) -> None:
|
||||
ui_base = self.config["server.ui_base_path"]
|
||||
if ui_base == "/":
|
||||
ui_base = ""
|
||||
directory = (self.config["server.override_resource_path"]
|
||||
or pkg_resources.resource_filename("maubot", "management/frontend/build"))
|
||||
self.app.router.add_static(f"{ui_base}/static", f"{directory}/static")
|
||||
self.setup_static_root_files(directory, ui_base)
|
||||
|
||||
with open(f"{directory}/index.html", "r") as file:
|
||||
index_html = file.read()
|
||||
|
||||
@web.middleware
|
||||
async def frontend_404_middleware(request, handler):
|
||||
if hasattr(handler, "__self__") and isinstance(handler.__self__, web.StaticResource):
|
||||
try:
|
||||
return await handler(request)
|
||||
except web.HTTPNotFound:
|
||||
return web.Response(body=index_html, content_type="text/html")
|
||||
return await handler(request)
|
||||
|
||||
self.app.middlewares.append(frontend_404_middleware)
|
||||
self.app.router.add_get(f"{ui_base}/", lambda _: web.Response(body=index_html,
|
||||
content_type="text/html"))
|
||||
self.app.router.add_get(ui_base, lambda _: web.HTTPFound(f"{ui_base}/"))
|
||||
|
||||
def setup_static_root_files(self, directory: str, ui_base: str) -> None:
|
||||
files = {
|
||||
"asset-manifest.json": "application/json",
|
||||
"manifest.json": "application/json",
|
||||
"favicon.png": "image/png",
|
||||
}
|
||||
for file, mime in files.items():
|
||||
with open(f"{directory}/{file}", "rb") as stream:
|
||||
data = stream.read()
|
||||
self.app.router.add_get(f"{ui_base}/{file}", lambda _: web.Response(body=data,
|
||||
content_type=mime))
|
||||
|
||||
def add_route(self, method: Method, path: PathBuilder, handler) -> None:
|
||||
self.app.router.add_route(method.value, str(path), handler)
|
||||
|
Loading…
Reference in New Issue
Block a user