Add administrator role plus manage dashboard and cleanup database migrations

This commit is contained in:
thiloho
2024-10-08 21:20:44 +02:00
parent c4f1bff2a9
commit 1b74e1e6fb
23 changed files with 625 additions and 87 deletions

View File

@@ -1,5 +1,6 @@
import { redirect } from "@sveltejs/kit";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { User } from "$lib/db-schema";
export const handle = async ({ event, resolve }) => {
if (!event.url.pathname.startsWith("/api/")) {
@@ -20,6 +21,13 @@ export const handle = async ({ event, resolve }) => {
throw redirect(303, "/");
}
if (
(userData.data as User).user_role !== "administrator" &&
event.url.pathname.includes("/manage")
) {
throw redirect(303, "/");
}
event.locals.user = userData.data;
}
}

View File

@@ -46,12 +46,16 @@
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
const target = event.target as HTMLTextAreaElement;
const newContent =
target.value.slice(0, target.selectionStart) +
`![](${fileUrl})` +
target.value.slice(target.selectionStart);
const markdownToInsert = `![](${fileUrl})`;
const cursorPosition = target.selectionStart;
const newContent = target.value.slice(0, target.selectionStart) + markdownToInsert;
target.value.slice(target.selectionStart);
previewContent.value = newContent;
const newCursorPosition = cursorPosition + markdownToInsert.length;
target.setSelectionRange(newCursorPosition, newCursorPosition);
target.focus();
} else {
return;
}

View File

@@ -51,8 +51,7 @@
background-color: var(--bg-primary);
border-radius: var(--border-radius);
border: var(--border-primary);
inline-size: var(--modal-width);
max-inline-size: 100%;
inline-size: min(var(--modal-width), 100%);
max-block-size: calc(100vh - var(--space-m));
overflow-y: auto;
z-index: 20;

View File

@@ -438,19 +438,21 @@ export interface User {
id: string;
username: string;
password_hash: string;
role: string;
user_role: string;
max_number_websites: number;
created_at: Date;
}
export interface UserInput {
id?: string;
username: string;
password_hash: string;
role?: string;
user_role?: string;
max_number_websites?: number;
created_at?: Date;
}
const user = {
tableName: "user",
columns: ["id", "username", "password_hash", "role", "created_at"],
columns: ["id", "username", "password_hash", "user_role", "max_number_websites", "created_at"],
requiredForInsert: ["username", "password_hash"],
primaryKey: "id",
foreignKeys: {},
@@ -464,6 +466,7 @@ export interface Website {
user_id: string;
content_type: string;
title: string;
max_storage_size: number;
is_published: boolean;
created_at: Date;
last_modified_at: Date;
@@ -474,6 +477,7 @@ export interface WebsiteInput {
user_id?: string;
content_type: string;
title: string;
max_storage_size?: number;
is_published?: boolean;
created_at?: Date;
last_modified_at?: Date;
@@ -486,6 +490,7 @@ const website = {
"user_id",
"content_type",
"title",
"max_storage_size",
"is_published",
"created_at",
"last_modified_at",

View File

@@ -151,8 +151,8 @@ export const md = (markdownContent: string, showToc = true) => {
try {
html = DOMPurify.sanitize(marked.parse(markdownContent, { async: false }));
} catch (_) {
html = "Failed to parse markdown";
} catch (error) {
html = JSON.stringify(error);
}
return html;

View File

@@ -1,9 +1,19 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ fetch, locals }) => {
const storageSizes = await apiRequest(
fetch,
`${API_BASE_PREFIX}/rpc/user_websites_storage_size`,
"GET",
{
returnData: true
}
);
return {
user: locals.user
user: locals.user,
storageSizes
};
};

View File

@@ -33,6 +33,30 @@
</ul>
</section>
{#if data.storageSizes.data.length > 0}
<section id="storage">
<h2>
<a href="#storage">Storage</a>
</h2>
<ul class="unpadded storage-grid">
{#each data.storageSizes.data as { website_title, storage_size_bytes, max_storage_bytes, max_storage_pretty, diff_storage_pretty }}
<li>
<strong>{website_title}</strong>
<label>
{max_storage_pretty} total &mdash; {diff_storage_pretty} free<br />
<meter
value={storage_size_bytes}
min="0"
max={max_storage_bytes}
high={max_storage_bytes * 0.75}
></meter>
</label>
</li>
{/each}
</ul>
</section>
{/if}
<section id="logout">
<h2>
<a href="#logout">Logout</a>
@@ -71,4 +95,22 @@
form[action="?/logout"] > button {
max-inline-size: fit-content;
}
.storage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 35ch), 1fr));
row-gap: var(--space-s);
column-gap: var(--space-m);
}
.storage-grid > li {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
meter {
inline-size: min(512px, 100%);
block-size: 2rem;
}
</style>

View File

@@ -0,0 +1,68 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async ({ fetch }) => {
const allUsers = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/all_user_websites?order=user_created_at.desc`,
"GET",
{
returnData: true
}
)
).data;
return {
allUsers,
API_BASE_PREFIX
};
};
export const actions: Actions = {
updateMaxWebsiteAmount: async ({ request, fetch }) => {
const data = await request.formData();
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
"PATCH",
{
body: {
max_number_websites: data.get("number-of-websites")
},
successMessage: "Successfully updated user website limit"
}
);
},
updateStorageLimit: async ({ request, fetch }) => {
const data = await request.formData();
console.log(`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`);
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${data.get("website-id")}`,
"PATCH",
{
body: {
max_storage_size: data.get("storage-size")
},
successMessage: "Successfully updated user website storage size"
}
);
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?id=eq.${data.get("user-id")}`,
"DELETE",
{
successMessage: "Successfully deleted user"
}
);
}
};

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Modal from "$lib/components/Modal.svelte";
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import type { ActionData, PageServerData } from "./$types";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import DateTime from "$lib/components/DateTime.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending.value}
<LoadingSpinner />
{/if}
<section id="all-users">
<h2>
<a href="#all-users">All users</a>
</h2>
<div class="scroll-container">
<table>
<thead>
<tr>
<th>Account creation</th>
<th>UUID</th>
<th>Username</th>
<th>Manage</th>
</tr>
</thead>
<tbody>
{#each data.allUsers as { user_id, user_created_at, username, max_number_websites, websites }}
<tr>
<td>
<DateTime date={user_created_at} />
</td>
<td>{user_id}</td>
<td>{username}</td>
<td>
<Modal id="manage-user-{user_id}" text="Manage">
<hgroup>
<h3>Manage user</h3>
<p>User "{username}"</p>
</hgroup>
<form
method="POST"
action="?/updateMaxWebsiteAmount"
use:enhance={enhanceForm({ reset: false })}
>
<input type="hidden" name="user-id" value={user_id} />
<label>
Number of websites allowed:
<input
type="number"
name="number-of-websites"
min="0"
value={max_number_websites}
/>
</label>
<button type="submit">Submit</button>
</form>
{#if websites.length > 0}
<h4>Websites</h4>
{#each websites as { id, title, max_storage_size }}
<details>
<summary>{title}</summary>
<div>
<form
method="POST"
action="?/updateStorageLimit"
use:enhance={enhanceForm({ reset: false })}
>
<input type="hidden" name="website-id" value={id} />
<label>
Storage limit in MB:
<input
type="number"
name="storage-size"
min="0"
value={max_storage_size}
/>
</label>
<button type="submit">Submit</button>
</form>
</div>
</details>
{/each}
{/if}
<h4>Delete user</h4>
<details>
<summary>Delete</summary>
<div>
<p>
<strong>Caution!</strong>
Deleting the user will irretrievably erase all their data.
</p>
<form
method="POST"
action="?/deleteUser"
use:enhance={enhanceForm({ closeModal: true })}
>
<input type="hidden" name="user-id" value={user_id} />
<button type="submit">Delete user</button>
</form>
</div>
</details>
</Modal>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<style>
form[action="?/deleteUser"] {
margin-block-start: var(--space-2xs);
}
</style>

View File

@@ -46,8 +46,7 @@
<a href="#publication-status">Publication status</a>
</h2>
<p>
Your website is published at:
<br />
Your website is published at:<br />
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
</p>
</section>

View File

@@ -53,6 +53,11 @@
{/if}
<ul class="link-wrapper unpadded">
{#if data.user}
{#if data.user.user_role === "administrator"}
<li>
<a href="/manage">Manage</a>
</li>
{/if}
<li>
<a href="/account">Account</a>
</li>