satellite/admin/ui: Create Web UI v1
This commit crate the Satellite Admin Web UI v1 * Bootstrap the files installing the project template: https://github.com/sveltejs/template * Add the prettier prettier-plugin-svelte NPM packages as dev dependencies. * Add an empty prettierrc.toml. * Transform the project template to use Typescript rather than Javasript. See: https://github.com/sveltejs/template#using-typescript * Replace the default favicon by the Storj logo. * Create components in companion of some Typescript code which allows to generate a simple HTML page based on an opinionated definition of API. * Implement all the Admin API client calls in the format that the UI generator components requires for rendering the UI from them. Change-Id: I58fa586d68dc8998e5d89db169b8e90204f0a96d
This commit is contained in:
parent
3122de4564
commit
583cdc6e0e
4
satellite/admin/ui/.gitignore
vendored
Normal file
4
satellite/admin/ui/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/node_modules/
|
||||||
|
/public/build/
|
||||||
|
|
||||||
|
.DS_Store
|
0
satellite/admin/ui/.prettierrc.toml
Normal file
0
satellite/admin/ui/.prettierrc.toml
Normal file
3
satellite/admin/ui/.vscode/extensions.json
vendored
Normal file
3
satellite/admin/ui/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
45
satellite/admin/ui/README.md
Normal file
45
satellite/admin/ui/README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Admin UI
|
||||||
|
|
||||||
|
## Implementation details
|
||||||
|
|
||||||
|
This is a project based on the [Svelte](https://svelte.dev) [template for apps](https://github.com/sveltejs/template).
|
||||||
|
|
||||||
|
The project templated was converted to Typescript following the instructions on its README.
|
||||||
|
|
||||||
|
The Web App is currently straightforward as we specified that v1 would be.
|
||||||
|
|
||||||
|
The v1 is just a simple web page that exposes the Admin API through some forms and allow to a call the API without needing to use some HTTP REST clients (e.g. Postman, cURL, etc.).
|
||||||
|
It doesn't offer any user authentication; the user has to know the API authorization token for using it.
|
||||||
|
|
||||||
|
|
||||||
|
The UI has a set of Svelte components that collaborate together to render an HTML form with input elements from the Admin API client.
|
||||||
|
The Svelte components expect some values of a certain Typescript interfaces, types, and classes, for being able to dynamically render the HTML form and elements.
|
||||||
|
|
||||||
|
Each source has a brief doc comment about its functionality.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Install the dependencies...
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
...then start [Rollup](https://rollupjs.org):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to [localhost:5000](http://localhost:5000). You should see your app running.
|
||||||
|
|
||||||
|
By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`.
|
||||||
|
|
||||||
|
|
||||||
|
## Building for production mode
|
||||||
|
|
||||||
|
To create an optimised version of the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
2487
satellite/admin/ui/package-lock.json
generated
Normal file
2487
satellite/admin/ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
satellite/admin/ui/package.json
Normal file
33
satellite/admin/ui/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "svelte-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c",
|
||||||
|
"dev": "rollup -c -w",
|
||||||
|
"start": "sirv public",
|
||||||
|
"validate": "svelte-check"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^17.0.0",
|
||||||
|
"@rollup/plugin-node-resolve": "^11.0.0",
|
||||||
|
"@rollup/plugin-typescript": "^8.0.0",
|
||||||
|
"@tsconfig/svelte": "^1.0.0",
|
||||||
|
"prettier": "^2.2.1",
|
||||||
|
"prettier-plugin-svelte": "^2.2.0",
|
||||||
|
"rollup": "^2.3.4",
|
||||||
|
"rollup-plugin-css-only": "^3.1.0",
|
||||||
|
"rollup-plugin-livereload": "^2.0.0",
|
||||||
|
"rollup-plugin-svelte": "^7.0.0",
|
||||||
|
"rollup-plugin-terser": "^7.0.0",
|
||||||
|
"svelte": "^3.0.0",
|
||||||
|
"svelte-check": "^1.0.0",
|
||||||
|
"svelte-preprocess": "^4.0.0",
|
||||||
|
"tslib": "^2.0.0",
|
||||||
|
"typescript": "^4.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-print-json": "^1.1.0",
|
||||||
|
"sirv-cli": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
BIN
satellite/admin/ui/public/favicon.ico
Normal file
BIN
satellite/admin/ui/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.2 KiB |
67
satellite/admin/ui/public/global.css
Normal file
67
satellite/admin/ui/public/global.css
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(0, 100, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: rgb(0, 80, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
-webkit-padding: 0.4em 0;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: #333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled):active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
18
satellite/admin/ui/public/index.html
Normal file
18
satellite/admin/ui/public/index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
|
||||||
|
<title>Storj Satellite Admin Console</title>
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||||
|
<link rel="stylesheet" href="/global.css" />
|
||||||
|
<link rel="stylesheet" href="/pretty-print-json.css" />
|
||||||
|
<link rel="stylesheet" href="/build/bundle.css" />
|
||||||
|
|
||||||
|
<script defer src="/build/bundle.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body></body>
|
||||||
|
</html>
|
62
satellite/admin/ui/public/pretty-print-json.css
Normal file
62
satellite/admin/ui/public/pretty-print-json.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/*! pretty-print-json v1.0.3 ~ github.com/center-key/pretty-print-json ~ MIT License */
|
||||||
|
.json-key {
|
||||||
|
color: brown;
|
||||||
|
}
|
||||||
|
.json-string {
|
||||||
|
color: olive;
|
||||||
|
}
|
||||||
|
.json-number {
|
||||||
|
color: navy;
|
||||||
|
}
|
||||||
|
.json-boolean {
|
||||||
|
color: teal;
|
||||||
|
}
|
||||||
|
.json-null {
|
||||||
|
color: dimgray;
|
||||||
|
}
|
||||||
|
.json-mark {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
a.json-link {
|
||||||
|
color: purple;
|
||||||
|
transition: all 400ms;
|
||||||
|
}
|
||||||
|
a.json-link:visited {
|
||||||
|
color: slategray;
|
||||||
|
}
|
||||||
|
a.json-link:hover {
|
||||||
|
color: blueviolet;
|
||||||
|
}
|
||||||
|
a.json-link:active {
|
||||||
|
color: slategray;
|
||||||
|
}
|
||||||
|
.dark-mode .json-key {
|
||||||
|
color: indianred;
|
||||||
|
}
|
||||||
|
.dark-mode .json-string {
|
||||||
|
color: darkkhaki;
|
||||||
|
}
|
||||||
|
.dark-mode .json-number {
|
||||||
|
color: deepskyblue;
|
||||||
|
}
|
||||||
|
.dark-mode .json-boolean {
|
||||||
|
color: mediumseagreen;
|
||||||
|
}
|
||||||
|
.dark-mode .json-null {
|
||||||
|
color: darkorange;
|
||||||
|
}
|
||||||
|
.dark-mode .json-mark {
|
||||||
|
color: silver;
|
||||||
|
}
|
||||||
|
.dark-mode a.json-link {
|
||||||
|
color: mediumorchid;
|
||||||
|
}
|
||||||
|
.dark-mode a.json-link:visited {
|
||||||
|
color: slategray;
|
||||||
|
}
|
||||||
|
.dark-mode a.json-link:hover {
|
||||||
|
color: violet;
|
||||||
|
}
|
||||||
|
.dark-mode a.json-link:active {
|
||||||
|
color: silver;
|
||||||
|
}
|
90
satellite/admin/ui/rollup.config.js
Normal file
90
satellite/admin/ui/rollup.config.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
// Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
import svelte from "rollup-plugin-svelte";
|
||||||
|
import commonjs from "@rollup/plugin-commonjs";
|
||||||
|
import resolve from "@rollup/plugin-node-resolve";
|
||||||
|
import livereload from "rollup-plugin-livereload";
|
||||||
|
import { terser } from "rollup-plugin-terser";
|
||||||
|
import sveltePreprocess from "svelte-preprocess";
|
||||||
|
import typescript from "@rollup/plugin-typescript";
|
||||||
|
import css from "rollup-plugin-css-only";
|
||||||
|
|
||||||
|
const production = !process.env.ROLLUP_WATCH;
|
||||||
|
|
||||||
|
function serve() {
|
||||||
|
let server;
|
||||||
|
|
||||||
|
function toExit() {
|
||||||
|
if (server) server.kill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
writeBundle() {
|
||||||
|
if (server) return;
|
||||||
|
server = require("child_process").spawn(
|
||||||
|
"npm",
|
||||||
|
["run", "start", "--", "--dev"],
|
||||||
|
{
|
||||||
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
|
shell: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
process.on("SIGTERM", toExit);
|
||||||
|
process.on("exit", toExit);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
input: "src/main.ts",
|
||||||
|
output: {
|
||||||
|
sourcemap: true,
|
||||||
|
format: "iife",
|
||||||
|
name: "app",
|
||||||
|
file: "public/build/bundle.js",
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
svelte({
|
||||||
|
preprocess: sveltePreprocess({ sourceMap: !production }),
|
||||||
|
compilerOptions: {
|
||||||
|
// enable run-time checks when not in production
|
||||||
|
dev: !production,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// we'll extract any component CSS out into
|
||||||
|
// a separate file - better for performance
|
||||||
|
css({ output: "bundle.css" }),
|
||||||
|
|
||||||
|
// If you have external dependencies installed from
|
||||||
|
// npm, you'll most likely need these plugins. In
|
||||||
|
// some cases you'll need additional configuration -
|
||||||
|
// consult the documentation for details:
|
||||||
|
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||||
|
resolve({
|
||||||
|
browser: true,
|
||||||
|
dedupe: ["svelte"],
|
||||||
|
}),
|
||||||
|
commonjs(),
|
||||||
|
typescript({
|
||||||
|
sourceMap: !production,
|
||||||
|
inlineSources: !production,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// In dev mode, call `npm run start` once
|
||||||
|
// the bundle has been generated
|
||||||
|
!production && serve(),
|
||||||
|
|
||||||
|
// Watch the `public` directory and refresh the
|
||||||
|
// browser on changes when not in production
|
||||||
|
!production && livereload("public"),
|
||||||
|
|
||||||
|
// If we're building for production (npm run build
|
||||||
|
// instead of npm run dev), minify
|
||||||
|
production && terser(),
|
||||||
|
],
|
||||||
|
watch: {
|
||||||
|
clearScreen: false,
|
||||||
|
},
|
||||||
|
};
|
87
satellite/admin/ui/src/Api.svelte
Normal file
87
satellite/admin/ui/src/Api.svelte
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
See LICENSE for copying information.
|
||||||
|
|
||||||
|
This component is the glue between the Admin API client and the "UIGenerator"
|
||||||
|
component.
|
||||||
|
|
||||||
|
It list all the operations that the API exposes and render the Web UI, through
|
||||||
|
the `UIGenerator.svelte` component, when the user selects the operation that
|
||||||
|
wants to perform.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Operation } from "./ui-generator";
|
||||||
|
import { Admin } from "./api";
|
||||||
|
|
||||||
|
import UIGen from "./UIGenerator.svelte";
|
||||||
|
|
||||||
|
const baseURL = `${window.location.protocol}//${window.location.host}/api`;
|
||||||
|
let api: Admin;
|
||||||
|
let selectedGroupOp: Operation[];
|
||||||
|
let selectedOp: Operation;
|
||||||
|
let authToken: string;
|
||||||
|
|
||||||
|
function confirmAuthToken() {
|
||||||
|
if (authToken) {
|
||||||
|
api = new Admin(baseURL, authToken);
|
||||||
|
} else {
|
||||||
|
api = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
In order to use the API you have to set the authentication token in the input
|
||||||
|
box and press enter or click the "confirm" button.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Token: <input
|
||||||
|
bind:value={authToken}
|
||||||
|
on:focus={() => {
|
||||||
|
api = null;
|
||||||
|
|
||||||
|
// This allows to select the empty item of the second select.
|
||||||
|
selectedOp = null;
|
||||||
|
}}
|
||||||
|
on:keyup={(e) => {
|
||||||
|
if (e.key.toLowerCase() === "enter") confirmAuthToken();
|
||||||
|
}}
|
||||||
|
type="password"
|
||||||
|
size="48"
|
||||||
|
/>
|
||||||
|
<button on:click={confirmAuthToken}>confirm</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if api}
|
||||||
|
<p>
|
||||||
|
Operation:
|
||||||
|
<select
|
||||||
|
bind:value={selectedGroupOp}
|
||||||
|
on:change={() => {
|
||||||
|
// This allows hiding the UIGen component when this select change until
|
||||||
|
// a new operations is selected in the following select element and also
|
||||||
|
// selecting the empty item of the select.
|
||||||
|
selectedOp = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option selected />
|
||||||
|
{#each Object.keys(api.operations) as group}
|
||||||
|
<option value={api.operations[group]}>{group}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if selectedGroupOp}
|
||||||
|
<select bind:value={selectedOp}>
|
||||||
|
<option selected />
|
||||||
|
{#each selectedGroupOp as op (op)}
|
||||||
|
<option value={op}>{op.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
{#if selectedOp}
|
||||||
|
<UIGen operation={selectedOp} />
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
50
satellite/admin/ui/src/App.svelte
Normal file
50
satellite/admin/ui/src/App.svelte
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
See LICENSE for copying information.
|
||||||
|
|
||||||
|
The Svelte root component for this Web App.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import Api from "./Api.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Satellite Admin</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Api />
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<p>version: v1</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1em;
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 97vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #ff3e00;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 4em;
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
header {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
107
satellite/admin/ui/src/UIGenerator.svelte
Normal file
107
satellite/admin/ui/src/UIGenerator.svelte
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
See LICENSE for copying information.
|
||||||
|
|
||||||
|
This component render the passed "operation" value as an HTML form and a set of
|
||||||
|
HTML elements to enter data and specify values.
|
||||||
|
|
||||||
|
See `ui-generator.ts` file for knowing about the `Operation` interface which is
|
||||||
|
the type of the "operation" value to be passed in.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { prettyPrintJson as prettyJSON } from "pretty-print-json";
|
||||||
|
import type { Operation } from "./ui-generator";
|
||||||
|
|
||||||
|
import UIInputText from "./UIGeneratorInputText.svelte";
|
||||||
|
import UISelect from "./UIGeneratorSelect.svelte";
|
||||||
|
import UITextarea from "./UIGeneratorTextarea.svelte";
|
||||||
|
|
||||||
|
type opArg = boolean | number | string;
|
||||||
|
|
||||||
|
function execOperation(op: Operation, args: opArg[]) {
|
||||||
|
result = op.func(...args).then((data) => {
|
||||||
|
form.reset();
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (operation !== prevOperation) {
|
||||||
|
opArgs = new Array(operation.params.length);
|
||||||
|
result = undefined;
|
||||||
|
prevOperation = operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export let operation: Operation;
|
||||||
|
let prevOperation: Operation;
|
||||||
|
let opArgs: any[] = new Array(operation.params.length);
|
||||||
|
let result: Promise<object | null>;
|
||||||
|
let form: HTMLFormElement;
|
||||||
|
|
||||||
|
let componentsMap = {
|
||||||
|
InputText: UIInputText,
|
||||||
|
Select: UISelect,
|
||||||
|
Textarea: UITextarea,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>{operation.desc}</p>
|
||||||
|
<form
|
||||||
|
bind:this={form}
|
||||||
|
on:submit|preventDefault={() => execOperation(operation, opArgs)}
|
||||||
|
>
|
||||||
|
{#each operation.params as param, i}
|
||||||
|
<svelte:component
|
||||||
|
this={componentsMap[param[1].constructor.name]}
|
||||||
|
label={param[0]}
|
||||||
|
config={param[1]}
|
||||||
|
bind:value={opArgs[i]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<input type="submit" value="submit" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<output>
|
||||||
|
{#if result !== undefined}
|
||||||
|
{#await result}
|
||||||
|
<p>Sending...</p>
|
||||||
|
{:then data}
|
||||||
|
<p class="successful">
|
||||||
|
<b>Operation successful</b>
|
||||||
|
{#if data != null}
|
||||||
|
<br /><br />
|
||||||
|
HTTP Response body:
|
||||||
|
<pre>{@html prettyJSON.toHtml(data)}</pre>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:catch err}
|
||||||
|
<p class="failure">
|
||||||
|
<b>Operation failed</b>
|
||||||
|
<br /><br />
|
||||||
|
{err.name}: {err.message}
|
||||||
|
{#if err.responseStatusCode}
|
||||||
|
<br />
|
||||||
|
HTTP Response status code: {err.responseStatusCode}
|
||||||
|
{#if err.responseBody}
|
||||||
|
<br />
|
||||||
|
HTTP Response body:
|
||||||
|
<pre>{@html prettyJSON.toHtml(err.responseBody)}</pre>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.failure b {
|
||||||
|
color: red;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successful b {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
75
satellite/admin/ui/src/UIGeneratorInputText.svelte
Normal file
75
satellite/admin/ui/src/UIGeneratorInputText.svelte
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
See LICENSE for copying information.
|
||||||
|
|
||||||
|
Children Svelte component of the `UIGenerator.svelte` component which renders
|
||||||
|
an HTML input element.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
import type { InputText } from "./ui-generator";
|
||||||
|
|
||||||
|
export let label: string;
|
||||||
|
export let config: InputText;
|
||||||
|
export let value: boolean | number | string = undefined;
|
||||||
|
|
||||||
|
// For avoiding Svelte validate errors with Typescript types, we cannot map
|
||||||
|
// the `value` variable directly to HTML elements.
|
||||||
|
let boolValue: boolean = undefined;
|
||||||
|
let numValue: number = undefined;
|
||||||
|
let strValue: string = undefined;
|
||||||
|
|
||||||
|
// Map the initial value property when has some value to the HTML element.
|
||||||
|
onMount(() => {
|
||||||
|
if (value) {
|
||||||
|
switch (typeof value) {
|
||||||
|
case "boolean":
|
||||||
|
boolValue = value;
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
numValue = value;
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
strValue = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (boolValue !== undefined) {
|
||||||
|
value = boolValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numValue !== undefined) {
|
||||||
|
value = numValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strValue !== undefined) {
|
||||||
|
value = strValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
{#if config.required}<sup>*</sup>{/if}:
|
||||||
|
{#if config.type === "checkbox"}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={boolValue}
|
||||||
|
required={config.required}
|
||||||
|
/>
|
||||||
|
{:else if config.type === "email"}
|
||||||
|
<input type="email" bind:value={strValue} required={config.required} />
|
||||||
|
{:else if config.type === "number"}
|
||||||
|
<input type="number" bind:value={numValue} required={config.required} />
|
||||||
|
{:else if config.type === "password"}
|
||||||
|
<input type="password" bind:value={strValue} required={config.required} />
|
||||||
|
{:else if config.type === "text"}
|
||||||
|
<input type="text" bind:value={strValue} required={config.required} />
|
||||||
|
{:else}
|
||||||
|
<p style="color: red;">BUG: not mapped input type: {config.type}</p>
|
||||||
|
{/if}
|
||||||
|
</label>
|
32
satellite/admin/ui/src/UIGeneratorSelect.svelte
Normal file
32
satellite/admin/ui/src/UIGeneratorSelect.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
See LICENSE for copying information.
|
||||||
|
|
||||||
|
Children Svelte component of the `UIGenerator.svelte` component which renders
|
||||||
|
an HTML select element
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Select } from "./ui-generator";
|
||||||
|
|
||||||
|
export let label: string;
|
||||||
|
export let config: Select;
|
||||||
|
export let value: boolean | number | string = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
{#if config.required}<sup>*</sup>{/if}:
|
||||||
|
{#if config.multiple}
|
||||||
|
<select bind:value required={config.required} multiple>
|
||||||
|
{#each config.options as option}
|
||||||
|
<option value={option.value}>{option.text}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<select bind:value required={config.required}>
|
||||||
|
{#each config.options as option}
|
||||||
|
<option value={option.value}>{option.text}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</label>
|
28
satellite/admin/ui/src/UIGeneratorTextarea.svelte
Normal file
28
satellite/admin/ui/src/UIGeneratorTextarea.svelte
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
See LICENSE for copying information.
|
||||||
|
|
||||||
|
Children Svelte component of the `UIGenerator.svelte` component which renders
|
||||||
|
an HTML text area element.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Textarea } from "./ui-generator";
|
||||||
|
|
||||||
|
export let label: string;
|
||||||
|
export let config: Textarea;
|
||||||
|
export let value: string = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<div>
|
||||||
|
{label}{#if config.required}<sup>*</sup>{/if}:
|
||||||
|
</div>
|
||||||
|
<textarea required={config.required} bind:value />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
row-gap: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
322
satellite/admin/ui/src/api.ts
Normal file
322
satellite/admin/ui/src/api.ts
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
// Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is the Admin API client exposing the API operations using the
|
||||||
|
* interfaces, types and classes of the `ui-generator.ts` for allowing the
|
||||||
|
* `UIGenerator.svelte` component to dynamically render their Web UI interface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Operation } from "./ui-generator";
|
||||||
|
import { InputText, Textarea } from "./ui-generator";
|
||||||
|
|
||||||
|
// API must be implemented by any class which expose the access to a specific
|
||||||
|
// API.
|
||||||
|
export interface API {
|
||||||
|
operations: {
|
||||||
|
[key: string]: Operation[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Admin {
|
||||||
|
readonly operations = {
|
||||||
|
APIKeys: [
|
||||||
|
{
|
||||||
|
name: "delete key",
|
||||||
|
desc: "Delete an API key",
|
||||||
|
params: [["API key", new InputText("text", true)]],
|
||||||
|
func: async (apiKey: string): Promise<null> => {
|
||||||
|
return this.fetch("DELETE", `apikeys/${apiKey}`) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
project: [
|
||||||
|
{
|
||||||
|
name: "create",
|
||||||
|
desc: "Add a new project to a specific user",
|
||||||
|
params: [
|
||||||
|
["Owner ID (user ID)", new InputText("text", true)],
|
||||||
|
["Project Name", new InputText("text", true)],
|
||||||
|
],
|
||||||
|
func: async (ownerId: string, projectName: string): Promise<object> => {
|
||||||
|
return this.fetch("POST", "projects", null, { ownerId, projectName });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete",
|
||||||
|
desc: "Delete a specific project",
|
||||||
|
params: [["Project ID", new InputText("text", true)]],
|
||||||
|
func: async (projectId: string): Promise<null> => {
|
||||||
|
return this.fetch("DELETE", `projects/${projectId}`) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get",
|
||||||
|
desc: "Get the information of a specific project",
|
||||||
|
params: [["Project ID", new InputText("text", true)]],
|
||||||
|
func: async (projectId: string): Promise<object> => {
|
||||||
|
return this.fetch("GET", `projects/${projectId}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update",
|
||||||
|
desc: "Update the information of a specific project",
|
||||||
|
params: [
|
||||||
|
["Project ID", new InputText("text", true)],
|
||||||
|
["Project Name", new InputText("text", true)],
|
||||||
|
["Description", new InputText("text", false)],
|
||||||
|
],
|
||||||
|
func: async (
|
||||||
|
projectId: string,
|
||||||
|
projectName: string,
|
||||||
|
description: string
|
||||||
|
): Promise<null> => {
|
||||||
|
return this.fetch("PUT", `projects/${projectId}`, null, {
|
||||||
|
projectName,
|
||||||
|
description,
|
||||||
|
}) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create API key",
|
||||||
|
desc: "Create a new API key for a specific project",
|
||||||
|
params: [
|
||||||
|
["Project ID", new InputText("text", true)],
|
||||||
|
["API key name", new InputText("text", true)],
|
||||||
|
],
|
||||||
|
func: async (projectId: string, name: string): Promise<object> => {
|
||||||
|
return this.fetch("POST", `projects/${projectId}/apikeys`, null, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete API key",
|
||||||
|
desc: "Delete a API key of a specific project",
|
||||||
|
params: [
|
||||||
|
["Project ID", new InputText("text", true)],
|
||||||
|
["API Key name", new InputText("text", true)],
|
||||||
|
],
|
||||||
|
func: async (projectId: string, apiKeyName: string): Promise<null> => {
|
||||||
|
return this.fetch(
|
||||||
|
"DELETE",
|
||||||
|
`projects/${projectId}/apikeys/${apiKeyName}`
|
||||||
|
) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get API keys",
|
||||||
|
desc: "Get the API keys of a specific project",
|
||||||
|
params: [["Project ID", new InputText("text", true)]],
|
||||||
|
func: async (projectId: string): Promise<object> => {
|
||||||
|
return this.fetch("GET", `projects/${projectId}/apiKeys`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get project usage",
|
||||||
|
desc: "Get the current usage of a specific project",
|
||||||
|
params: [["Project ID", new InputText("text", true)]],
|
||||||
|
func: async (projectId: string): Promise<object> => {
|
||||||
|
return this.fetch("GET", `projects/${projectId}/usage`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get project limits",
|
||||||
|
desc: "Get the current limits of a specific project",
|
||||||
|
params: [["Project ID", new InputText("text", true)]],
|
||||||
|
func: async (projectId: string): Promise<object> => {
|
||||||
|
return this.fetch("GET", `projects/${projectId}/limit`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update project limits",
|
||||||
|
desc: "Update the limits of a specific project",
|
||||||
|
params: [
|
||||||
|
["Project ID", new InputText("text", true)],
|
||||||
|
["Storage (in bytes)", new InputText("number", false)],
|
||||||
|
["Bandwidth (in bytes)", new InputText("number", false)],
|
||||||
|
["Rate (requests per second)", new InputText("number", false)],
|
||||||
|
["Buckets (maximum number)", new InputText("number", false)],
|
||||||
|
],
|
||||||
|
func: async (
|
||||||
|
projectId: string,
|
||||||
|
usage: number,
|
||||||
|
bandwidth: number,
|
||||||
|
rate: number,
|
||||||
|
buckets: number
|
||||||
|
): Promise<null> => {
|
||||||
|
const query = this.urlQueryFromObject({
|
||||||
|
usage,
|
||||||
|
bandwidth,
|
||||||
|
rate,
|
||||||
|
buckets,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query === "") {
|
||||||
|
throw new APIError(
|
||||||
|
"nothing to update, at least one limit must be set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fetch(
|
||||||
|
"PUT",
|
||||||
|
`projects/${projectId}/limit`,
|
||||||
|
query
|
||||||
|
) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
user: [
|
||||||
|
{
|
||||||
|
name: "create",
|
||||||
|
desc: "Create a new user account",
|
||||||
|
params: [
|
||||||
|
["email", new InputText("email", true)],
|
||||||
|
["full name", new InputText("text", false)],
|
||||||
|
["password", new InputText("password", true)],
|
||||||
|
],
|
||||||
|
func: async (
|
||||||
|
email: string,
|
||||||
|
fullName: string,
|
||||||
|
password: string
|
||||||
|
): Promise<object> => {
|
||||||
|
return this.fetch("POST", "users", null, {
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete",
|
||||||
|
desc: "Delete a user's account",
|
||||||
|
params: [["email", new InputText("email", true)]],
|
||||||
|
func: async (email: string): Promise<null> => {
|
||||||
|
return this.fetch("DELETE", `users/${email}`) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get",
|
||||||
|
desc: "Get the information of a user's account",
|
||||||
|
params: [["email", new InputText("email", true)]],
|
||||||
|
func: async (email: string): Promise<object> => {
|
||||||
|
return this.fetch("GET", `users/${email}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update",
|
||||||
|
desc: `Update the information of a user's account.
|
||||||
|
Blank fields will not be updated.`,
|
||||||
|
params: [
|
||||||
|
["current user's email", new InputText("email", true)],
|
||||||
|
["new email", new InputText("email", false)],
|
||||||
|
["full name", new InputText("text", false)],
|
||||||
|
["short name", new InputText("text", false)],
|
||||||
|
["partner ID", new InputText("text", false)],
|
||||||
|
["password hash", new InputText("text", false)],
|
||||||
|
],
|
||||||
|
func: async (
|
||||||
|
currentEmail: string,
|
||||||
|
email?: string,
|
||||||
|
fullName?: string,
|
||||||
|
shortName?: string,
|
||||||
|
partnerID?: string,
|
||||||
|
passwordHash?: string
|
||||||
|
): Promise<null> => {
|
||||||
|
return this.fetch("PUT", `users/${currentEmail}`, null, {
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
shortName,
|
||||||
|
partnerID,
|
||||||
|
passwordHash,
|
||||||
|
}) as Promise<null>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly baseURL: string;
|
||||||
|
|
||||||
|
constructor(baseURL: string, private readonly authToken: string) {
|
||||||
|
this.baseURL = baseURL.endsWith("/")
|
||||||
|
? baseURL.substring(0, baseURL.length - 1)
|
||||||
|
: baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async fetch(
|
||||||
|
method: "DELETE" | "GET" | "POST" | "PUT",
|
||||||
|
path: string,
|
||||||
|
query?: string,
|
||||||
|
data?: object
|
||||||
|
): Promise<object | null> {
|
||||||
|
const url = this.apiURL(path, query);
|
||||||
|
const headers = new window.Headers({
|
||||||
|
Authorization: this.authToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: string;
|
||||||
|
if (data) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await window.fetch(url, { method, headers, body });
|
||||||
|
if (!resp.ok) {
|
||||||
|
let body: object;
|
||||||
|
if (resp.headers.get("Content-Type") === "application/json") {
|
||||||
|
body = await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new APIError("server response error", resp.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.headers.get("Content-Type") === "application/json") {
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected apiURL(path: string, query?: string): string {
|
||||||
|
path = path.startsWith("/") ? path.substring(1) : path;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
query = "";
|
||||||
|
} else {
|
||||||
|
query = "?" + query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.baseURL}/${path}${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform an object to a URL query string.
|
||||||
|
* It discards any object field whose value is undefined.
|
||||||
|
*
|
||||||
|
* NOTE it doesn't recurs on values which are objects.
|
||||||
|
*/
|
||||||
|
protected urlQueryFromObject(values: object): string {
|
||||||
|
const queryParts = [];
|
||||||
|
|
||||||
|
for (const name of Object.keys(values)) {
|
||||||
|
const val = values[name];
|
||||||
|
if (val === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParts.push(`${name}=${encodeURIComponent(val)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryParts.join("&");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class APIError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly msg: string,
|
||||||
|
public readonly responseStatusCode?: number,
|
||||||
|
public readonly responseBody?: object | string
|
||||||
|
) {
|
||||||
|
super(msg);
|
||||||
|
}
|
||||||
|
}
|
14
satellite/admin/ui/src/main.ts
Normal file
14
satellite/admin/ui/src/main.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Svelte App entry point.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import App from "./App.svelte";
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
54
satellite/admin/ui/src/ui-generator.ts
Normal file
54
satellite/admin/ui/src/ui-generator.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (C) 2021 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A set of interfaces, classes and types that allow the `UIGenerator.svelte`
|
||||||
|
* component to generate an HTML form with a set of HTML elements to enter data
|
||||||
|
* and specify values.
|
||||||
|
*
|
||||||
|
* A REST API can use these types to dynamically generate a Web UI for it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Operation {
|
||||||
|
// name is the operation name.
|
||||||
|
name: string;
|
||||||
|
// desc is the description of the operation.
|
||||||
|
desc: string;
|
||||||
|
// params is an array of tuples where each tuple corresponds to one parameter
|
||||||
|
// of 'func'. Each tuple has 2 elements, the first is the parameters name and
|
||||||
|
// the second how it's mapped to the UI. The orders must match the order of
|
||||||
|
// the 'func' parameters.
|
||||||
|
// The parameter's name is what is going to be show next to the input field,
|
||||||
|
// so it has to be descriptive for the users to know that they have to set.
|
||||||
|
params: [string, ParamUI][];
|
||||||
|
// func is the API function call. They always have to return a promise which
|
||||||
|
// resolves with an object or null.
|
||||||
|
// On a resolved promise, an object is the response body of an API call and
|
||||||
|
// null is used when the API operation doesn't return any payload (e.g. PUT
|
||||||
|
// operations).
|
||||||
|
func: (...p: any) => Promise<object | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParamUI = InputText | Select | Textarea;
|
||||||
|
|
||||||
|
export class InputText {
|
||||||
|
constructor(
|
||||||
|
public readonly type: "checkbox" | "email" | "number" | "password" | "text",
|
||||||
|
public readonly required: boolean
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Select {
|
||||||
|
constructor(
|
||||||
|
public readonly multiple: boolean,
|
||||||
|
public readonly required: boolean,
|
||||||
|
public readonly options: {
|
||||||
|
text: string;
|
||||||
|
value: boolean | number | string;
|
||||||
|
}[]
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Textarea {
|
||||||
|
constructor(public readonly required: boolean) {}
|
||||||
|
}
|
6
satellite/admin/ui/tsconfig.json
Normal file
6
satellite/admin/ui/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user