add initial concept of "superuser" accounts
This commit is contained in:
parent
314a7f2be0
commit
6d76dec265
14 changed files with 134 additions and 12 deletions
|
@ -17,7 +17,7 @@ function serve() {
|
||||||
return {
|
return {
|
||||||
writeBundle() {
|
writeBundle() {
|
||||||
if (server) return;
|
if (server) return;
|
||||||
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
|
server = require('child_process').spawn('yarn', ['run', 'start', '--', '--dev'], {
|
||||||
stdio: ['ignore', 'inherit', 'inherit'],
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
shell: true
|
shell: true
|
||||||
});
|
});
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
<div class="message">
|
<div class="message">
|
||||||
<span class="author">{ message.author_username }</span>
|
<span class="author">{ message.author_username }</span>
|
||||||
<span class="message-content" class:pending={ message._isPending }>{ message.content }</span>
|
<span class="message-content" class:pending={ message._isPending }>{ message.content }</span>
|
||||||
{#if userInfoStore.value && message.author_id === userInfoStore.value.id}
|
{#if userInfoStore.value && (message.author_id === userInfoStore.value.id || userInfoStore.value.is_superuser)}
|
||||||
<button class="icon-button icon-button-auto edit-message" on:click="{ () => overlayStore.open('editMessage', { message }) }">
|
<button class="icon-button icon-button-auto edit-message" on:click="{ () => overlayStore.open('editMessage', { message }) }">
|
||||||
<MoreVerticalIcon />
|
<MoreVerticalIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
{#if channel._hasUnreads}
|
{#if channel._hasUnreads}
|
||||||
<span class="unread-indicator">•</span>
|
<span class="unread-indicator">•</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $userInfoStore && channel.owner_id === $userInfoStore.id}
|
{#if $userInfoStore && (channel.owner_id === $userInfoStore.id || $userInfoStore.is_superuser)}
|
||||||
<button class="icon-button icon-button-auto" on:click|stopPropagation="{ () => overlayStore.open('editChannel', { channel }) }">
|
<button class="icon-button icon-button-auto" on:click|stopPropagation="{ () => overlayStore.open('editChannel', { channel }) }">
|
||||||
<MoreVerticalIcon />
|
<MoreVerticalIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import CreateAccount from "./CreateAccount.svelte";
|
import CreateAccount from "./CreateAccount.svelte";
|
||||||
import EditMessage from "./EditMessage.svelte";
|
import EditMessage from "./EditMessage.svelte";
|
||||||
import Settings from "./Settings.svelte";
|
import Settings from "./Settings.svelte";
|
||||||
|
import Prompt from "./Prompt.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $overlayStore.createChannel}
|
{#if $overlayStore.createChannel}
|
||||||
|
@ -30,3 +31,6 @@
|
||||||
{#if $overlayStore.settings}
|
{#if $overlayStore.settings}
|
||||||
<Settings />
|
<Settings />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $overlayStore.prompt}
|
||||||
|
<Prompt { ...$overlayStore.prompt } />
|
||||||
|
{/if}
|
||||||
|
|
53
frontend/src/components/overlays/Prompt.svelte
Normal file
53
frontend/src/components/overlays/Prompt.svelte
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<script>
|
||||||
|
import { maybeFade, maybeFly } from "../../animations";
|
||||||
|
import { quintInOut } from "svelte/easing";
|
||||||
|
import { overlayStore } from "../../stores";
|
||||||
|
|
||||||
|
export let onSubmit = async () => {};
|
||||||
|
export let onClose = async () => {};
|
||||||
|
export let heading = "Prompt";
|
||||||
|
export let valueName = "Value";
|
||||||
|
|
||||||
|
let userInput = "";
|
||||||
|
let buttonsEnabled = true;
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
await onClose();
|
||||||
|
overlayStore.close("prompt");
|
||||||
|
};
|
||||||
|
const save = async () => {
|
||||||
|
buttonsEnabled = false;
|
||||||
|
await onSubmit(userInput);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
const onKeydown = async (e) => {
|
||||||
|
if (e.code !== "Enter")
|
||||||
|
return;
|
||||||
|
|
||||||
|
await save();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" transition:maybeFade="{{ duration: 300, easing: quintInOut }}" on:click="{ close }" on:keydown="{ onKeydown }">
|
||||||
|
<div class="modal" transition:maybeFly="{{ duration: 300, easing: quintInOut, y: 10 }}" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="h4">{ heading }</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="input-label">
|
||||||
|
{ valueName }
|
||||||
|
<input class="input full-width" minlength="1" maxlength="32" bind:value={ userInput } />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button>
|
||||||
|
<button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -4,6 +4,8 @@
|
||||||
import { overlayStore, userInfoStore, smallViewport, theme, doAnimations } from "../../stores";
|
import { overlayStore, userInfoStore, smallViewport, theme, doAnimations } from "../../stores";
|
||||||
import { logOut } from "../../auth";
|
import { logOut } from "../../auth";
|
||||||
import { maybeFade, maybeFly } from "../../animations";
|
import { maybeFade, maybeFly } from "../../animations";
|
||||||
|
import request from "../../request";
|
||||||
|
import { apiRoute } from "../../storage";
|
||||||
|
|
||||||
const close = () => overlayStore.close("settings");
|
const close = () => overlayStore.close("settings");
|
||||||
|
|
||||||
|
@ -14,6 +16,23 @@
|
||||||
message: "Logged out"
|
message: "Logged out"
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doSuperuserPrompt = () => {
|
||||||
|
overlayStore.open("prompt", {
|
||||||
|
heading: "Become Superuser",
|
||||||
|
valueName: "Superuser Key",
|
||||||
|
async onSubmit(value) {
|
||||||
|
const { ok } = await request("POST", apiRoute("users/self/promote"), true, {
|
||||||
|
key: value
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
overlayStore.open("toast", { message: "You have been promoted to superuser" });
|
||||||
|
} else {
|
||||||
|
overlayStore.open("toast", { message: "Failed to promote to superuser" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -53,7 +72,7 @@
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner-logout-button {
|
.account-buttons {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -64,7 +83,10 @@
|
||||||
<div class="settings-card full-width">
|
<div class="settings-card full-width">
|
||||||
<AtSignIcon />
|
<AtSignIcon />
|
||||||
<span class="h5 top-bar-heading">{ $userInfoStore ? $userInfoStore.username : "" }</span>
|
<span class="h5 top-bar-heading">{ $userInfoStore ? $userInfoStore.username : "" }</span>
|
||||||
<button class="button button-red inner-logout-button" on:click="{ doLogout }">Log Out</button>
|
<div class="account-buttons">
|
||||||
|
<button class="button button-red" on:click="{ doSuperuserPrompt }">Become Superuser</button>
|
||||||
|
<button class="button button-red" on:click="{ doLogout }">Log Out</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="separator" />
|
<div class="separator" />
|
||||||
|
|
|
@ -303,6 +303,7 @@ class OverlayStore extends Store {
|
||||||
createAccount: null,
|
createAccount: null,
|
||||||
editMessage: null,
|
editMessage: null,
|
||||||
settings: null,
|
settings: null,
|
||||||
|
prompt: null,
|
||||||
}, "OverlayStore");
|
}, "OverlayStore");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,8 @@ export default async function databaseInit() {
|
||||||
CREATE TABLE IF NOT EXISTS users(
|
CREATE TABLE IF NOT EXISTS users(
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(32) UNIQUE NOT NULL,
|
username VARCHAR(32) UNIQUE NOT NULL,
|
||||||
password TEXT
|
password TEXT,
|
||||||
|
is_superuser BOOLEAN
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channels(
|
CREATE TABLE IF NOT EXISTS channels(
|
||||||
|
|
|
@ -4,6 +4,7 @@ export const errors = {
|
||||||
BAD_AUTH: { code: 6003, message: "Bad authentication" },
|
BAD_AUTH: { code: 6003, message: "Bad authentication" },
|
||||||
NOT_FOUND: { code: 6004, message: "Not found" },
|
NOT_FOUND: { code: 6004, message: "Not found" },
|
||||||
FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" },
|
FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" },
|
||||||
|
BAD_SUPERUSER_KEY: { code: 6006, message: "Bad superuser key" },
|
||||||
GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" },
|
GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" },
|
||||||
FEATURE_DISABLED: { code: 7002, message: "This feature is disabled" }
|
FEATURE_DISABLED: { code: 7002, message: "This feature is disabled" }
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { getMessageById, getMessagesByChannelFirstPage, getMessagesByChannelPage
|
||||||
import { errors } from "../../../errors";
|
import { errors } from "../../../errors";
|
||||||
import { dispatch, dispatchChannelSubscribe } from "../../../gateway";
|
import { dispatch, dispatchChannelSubscribe } from "../../../gateway";
|
||||||
import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype";
|
import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype";
|
||||||
|
import serverConfig from "../../../serverconfig";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
@ -19,6 +20,10 @@ router.post(
|
||||||
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
|
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serverConfig.superuserRequirement.createChannel && !req.user.is_superuser) {
|
||||||
|
return res.status(403).json({ ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS });
|
||||||
|
}
|
||||||
|
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, req.user.id]);
|
const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, req.user.id]);
|
||||||
if (result.rowCount < 1) {
|
if (result.rowCount < 1) {
|
||||||
|
@ -60,7 +65,7 @@ router.put(
|
||||||
...errors.NOT_FOUND
|
...errors.NOT_FOUND
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (permissionCheckResult.rows[0].owner_id !== req.user.id) {
|
if (permissionCheckResult.rows[0].owner_id !== req.user.id && !req.user.is_superuser) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
||||||
});
|
});
|
||||||
|
@ -106,7 +111,7 @@ router.delete(
|
||||||
...errors.NOT_FOUND
|
...errors.NOT_FOUND
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (permissionCheckResult.rows[0].owner_id !== req.user.id) {
|
if (permissionCheckResult.rows[0].owner_id !== req.user.id && !req.user.is_superuser) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
||||||
});
|
});
|
||||||
|
|
|
@ -27,7 +27,7 @@ router.delete(
|
||||||
...errors.NOT_FOUND
|
...errors.NOT_FOUND
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (permissionCheckResult.rows[0].author_id !== req.user.id) {
|
if (permissionCheckResult.rows[0].author_id !== req.user.id && !req.user.is_superuser) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
||||||
});
|
});
|
||||||
|
@ -72,7 +72,7 @@ router.put(
|
||||||
...errors.NOT_FOUND
|
...errors.NOT_FOUND
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (permissionCheckResult.rows[0].author_id !== req.user.id) {
|
if (permissionCheckResult.rows[0].author_id !== req.user.id && !req.user.is_superuser) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { authenticateRoute, signToken } from "../../../auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const superuserKey = process.env.SUPERUSER_KEY || "";
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/register",
|
"/register",
|
||||||
body("username").isLength({ min: 3, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
|
body("username").isLength({ min: 3, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
|
||||||
|
@ -32,7 +34,7 @@ router.post(
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hash(password, 10);
|
const hashedPassword = await hash(password, 10);
|
||||||
const insertedUser = await query("INSERT INTO users(username, password) VALUES ($1, $2) RETURNING id, username", [username, hashedPassword]);
|
const insertedUser = await query("INSERT INTO users(username, password, is_superuser) VALUES ($1, $2, $3) RETURNING id, username, is_superuser", [username, hashedPassword, false]);
|
||||||
if (insertedUser.rowCount < 1) {
|
if (insertedUser.rowCount < 1) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
...errors.GOT_NO_DATABASE_DATA
|
...errors.GOT_NO_DATABASE_DATA
|
||||||
|
@ -78,4 +80,31 @@ router.get(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/self/promote",
|
||||||
|
authenticateRoute(),
|
||||||
|
body("key").isLength({ min: 1, max: 3000 }),
|
||||||
|
async (req, res) => {
|
||||||
|
|
||||||
|
const validationErrors = validationResult(req);
|
||||||
|
if (!validationErrors.isEmpty()) {
|
||||||
|
return res.status(403).json({ ...errors.BAD_SUPERUSER_KEY });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = req.body;
|
||||||
|
|
||||||
|
if (superuserKey && superuserKey.length >= 1 && key === superuserKey && req.user) {
|
||||||
|
const updateUserResult = await query("UPDATE users SET is_superuser = true WHERE id = $1", [req.user.id]);
|
||||||
|
if (updateUserResult.rowCount < 1) {
|
||||||
|
return res.status(500).json({
|
||||||
|
...errors.GOT_NO_DATABASE_DATA
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(200).json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(403).json({ ...errors.BAD_SUPERUSER_KEY });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
5
src/serverconfig.ts
Normal file
5
src/serverconfig.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export default {
|
||||||
|
superuserRequirement: {
|
||||||
|
createChannel: true
|
||||||
|
},
|
||||||
|
};
|
3
src/types/user.d.ts
vendored
3
src/types/user.d.ts
vendored
|
@ -1,5 +1,6 @@
|
||||||
interface User {
|
interface User {
|
||||||
password?: string,
|
password?: string,
|
||||||
username: string,
|
username: string,
|
||||||
id: number
|
id: number,
|
||||||
|
is_superuser: boolean
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue