✨ 🚧 Add admin api and ui for timetable management
This commit is contained in:
78
server/api/admin.js
Normal file
78
server/api/admin.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import Prisma from "@prisma/client";
|
||||||
|
const prisma = new Prisma.PrismaClient();
|
||||||
|
|
||||||
|
export function registerAdmin(app) {
|
||||||
|
app.get("/api/admin/timetable", listTimetables);
|
||||||
|
app.post("/api/admin/timetable", createTimetable);
|
||||||
|
app.put("/api/admin/timetable", editTimetable);
|
||||||
|
app.delete("/api/admin/timetable", deleteTimetable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMissingArguments(res) {
|
||||||
|
res.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: "missing_arguments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTimetables(_, res) {
|
||||||
|
res.send(
|
||||||
|
await prisma.timetable.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
class: true,
|
||||||
|
source: true,
|
||||||
|
trusted: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimetable(req, res) {
|
||||||
|
let data = req.body;
|
||||||
|
if (!data.title || !data.data || !data.class) {
|
||||||
|
sendMissingArguments(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timetable = await prisma.timetable.create({
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
res.status(201).send(timetable);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editTimetable(req, res) {
|
||||||
|
let id = parseInt(req.query.id);
|
||||||
|
if (!id) {
|
||||||
|
sendMissingArguments(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const timetable = await prisma.timetable.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
res.status(201).send(timetable);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTimetable(req, res) {
|
||||||
|
if (!req.query.id) {
|
||||||
|
sendMissingArguments(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await prisma.timetable.delete({
|
||||||
|
where: {
|
||||||
|
id: parseInt(req.query.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.status(200).send();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e);
|
||||||
|
}
|
||||||
|
}
|
@ -32,6 +32,18 @@ export async function hasPermission(sessionToken, permission, forValue) {
|
|||||||
return hasPermission;
|
return hasPermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkAdmin(req, res, next) {
|
||||||
|
if (!(await hasPermission(req.locals.session, "admin"))) {
|
||||||
|
res.status(401).send({
|
||||||
|
success: false,
|
||||||
|
error: "admin_only",
|
||||||
|
message: "You need to be admin to do this!",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyKey(sessionToken, key) {
|
export async function applyKey(sessionToken, key) {
|
||||||
if (!key) return false;
|
if (!key) return false;
|
||||||
const foundKey = await prisma.key.findUnique({
|
const foundKey = await prisma.key.findUnique({
|
||||||
|
@ -17,6 +17,8 @@ import auth from "./api/auth.js";
|
|||||||
import { Parser } from "./parser/index.js";
|
import { Parser } from "./parser/index.js";
|
||||||
import { BolleClient } from "./parser/bolle.js";
|
import { BolleClient } from "./parser/bolle.js";
|
||||||
import { parseSubstitutionPlan } from "./parser/untis.js";
|
import { parseSubstitutionPlan } from "./parser/untis.js";
|
||||||
|
import { registerAdmin } from "./api/admin.js";
|
||||||
|
import { checkAdmin } from "./api/permission.js";
|
||||||
|
|
||||||
// Check that credentials are supplied
|
// Check that credentials are supplied
|
||||||
if (
|
if (
|
||||||
@ -69,6 +71,11 @@ app.get("/api/substitutions", getSubstitutions);
|
|||||||
app.get("/api/history", getHistory);
|
app.get("/api/history", getHistory);
|
||||||
app.get("/api/classes", getClasses);
|
app.get("/api/classes", getClasses);
|
||||||
app.post("/api/token", auth.token);
|
app.post("/api/token", auth.token);
|
||||||
|
|
||||||
|
// Register Admin endpoints
|
||||||
|
app.use("/api/admin", checkAdmin);
|
||||||
|
registerAdmin(app);
|
||||||
|
|
||||||
// Respond with 400 for non-existent endpoints
|
// Respond with 400 for non-existent endpoints
|
||||||
app.get("/api/*", (_req, res) => {
|
app.get("/api/*", (_req, res) => {
|
||||||
res.sendStatus(400);
|
res.sendStatus(400);
|
||||||
|
28
src/components/settings/expand-section.vue
Normal file
28
src/components/settings/expand-section.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon } from "lucide-vue-next";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const collapsed = ref(true);
|
||||||
|
|
||||||
|
defineProps(["title"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="title" @click="collapsed = !collapsed">
|
||||||
|
<ChevronRightIcon v-if="collapsed" />
|
||||||
|
<ChevronDownIcon v-else />
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<slot v-if="!collapsed" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 19px;
|
||||||
|
margin: 5px 0px;
|
||||||
|
}
|
||||||
|
</style>
|
@ -11,7 +11,7 @@ import {
|
|||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { DownloadIcon } from "lucide-vue-next";
|
import { DownloadIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps(["timetable", "editable", "remote", "selected"]);
|
defineProps(["timetable", "editable", "remote", "selected", "admin"]);
|
||||||
defineEmits(["click", "edit", "delete", "copy", "export", "upload"]);
|
defineEmits(["click", "edit", "delete", "copy", "export", "upload"]);
|
||||||
|
|
||||||
const deleteConfirm = ref(false);
|
const deleteConfirm = ref(false);
|
||||||
@ -32,16 +32,17 @@ watch(deleteConfirm, (value) => {
|
|||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">{{ timetable.title }}</span>
|
<span class="name">{{ timetable.title }}</span>
|
||||||
<span class="detail"
|
<span class="detail"
|
||||||
>{{ $t("settings.source") }}: {{ timetable.source }}</span
|
>{{ $t("settings.source") }}: {{ timetable.source }}
|
||||||
|
<span v-if="admin">, Class: {{ timetable.class }}</span></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<DownloadIcon @click="$emit('export')" />
|
<DownloadIcon v-if="!admin" @click="$emit('export')" />
|
||||||
<Edit2Icon v-if="editable && !remote" @click="$emit('edit')" />
|
<Edit2Icon v-if="editable && !remote" @click="$emit('edit')" />
|
||||||
<UploadCloudIcon v-if="editable && remote" @click="$emit('upload')" />
|
<UploadCloudIcon v-if="editable && remote" @click="$emit('upload')" />
|
||||||
<CopyIcon @click="$emit('copy')" />
|
<CopyIcon v-if="!admin" @click="$emit('copy')" />
|
||||||
<TrashIcon
|
<TrashIcon
|
||||||
v-if="editable && !remote && !deleteConfirm"
|
v-if="editable && !remote && !deleteConfirm"
|
||||||
@click="deleteConfirm = true"
|
@click="deleteConfirm = true"
|
||||||
|
@ -2,6 +2,7 @@ import { sessionInfo } from "@/store";
|
|||||||
|
|
||||||
export function hasPermission(permission, forValue) {
|
export function hasPermission(permission, forValue) {
|
||||||
let hasPermission = false;
|
let hasPermission = false;
|
||||||
|
if (!sessionInfo.value.permissions) return false;
|
||||||
for (const perm of sessionInfo.value.permissions) {
|
for (const perm of sessionInfo.value.permissions) {
|
||||||
if (perm == permission) hasPermission = true;
|
if (perm == permission) hasPermission = true;
|
||||||
else if (perm == permission + ":" + forValue) hasPermission = true;
|
else if (perm == permission + ":" + forValue) hasPermission = true;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import { shouldLogin } from "@/store";
|
import { shouldLogin } from "@/store";
|
||||||
|
import { hasPermission } from "@/permission";
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import TimetableView from "@/views/TimetableView.vue";
|
import TimetableView from "@/views/TimetableView.vue";
|
||||||
import SubstitutionView from "@/views/SubstitutionView.vue";
|
import SubstitutionView from "@/views/SubstitutionView.vue";
|
||||||
@ -13,6 +14,7 @@ import TimetableEditor from "@/views/settings/TimetableEditor.vue";
|
|||||||
import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue";
|
import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue";
|
||||||
import AppearanceSettings from "@/views/settings/AppearanceSettings.vue";
|
import AppearanceSettings from "@/views/settings/AppearanceSettings.vue";
|
||||||
import KeySettings from "@/views/settings/KeySettings.vue";
|
import KeySettings from "@/views/settings/KeySettings.vue";
|
||||||
|
import AdminSettings from "@/views/settings/AdminSettings.vue";
|
||||||
import AboutPage from "@/views/settings/AboutPage.vue";
|
import AboutPage from "@/views/settings/AboutPage.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@ -76,6 +78,11 @@ const router = createRouter({
|
|||||||
name: "title.settings.keys",
|
name: "title.settings.keys",
|
||||||
component: KeySettings,
|
component: KeySettings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "admin",
|
||||||
|
name: "title.settings.admin",
|
||||||
|
component: AdminSettings,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "about",
|
path: "about",
|
||||||
name: "title.settings.about",
|
name: "title.settings.about",
|
||||||
@ -108,10 +115,14 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export let lastDataRoute = ref();
|
export let lastDataRoute = ref();
|
||||||
router.beforeEach((_to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
if (from.meta.dataView) {
|
if (from.meta.dataView) {
|
||||||
lastDataRoute.value = from;
|
lastDataRoute.value = from;
|
||||||
}
|
}
|
||||||
|
if (to.name == "title.settings.admin" && !hasPermission("admin")) {
|
||||||
|
alert("Nope");
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ export const strings = {
|
|||||||
groups: "Timetable Groups",
|
groups: "Timetable Groups",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
keys: "Manage Keys",
|
keys: "Manage Keys",
|
||||||
|
admin: "Admin Settings",
|
||||||
about: "About",
|
about: "About",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ScrollableContainer from "@/components/scrollable-container.vue";
|
import ScrollableContainer from "@/components/scrollable-container.vue";
|
||||||
import PageCard from "@/components/settings/page-card.vue";
|
import PageCard from "@/components/settings/page-card.vue";
|
||||||
|
import { hasPermission } from "@/permission";
|
||||||
import {
|
import {
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
CopyCheckIcon,
|
CopyCheckIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
|
WrenchIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
@ -41,6 +43,19 @@ import {
|
|||||||
:icon="KeyRoundIcon"
|
:icon="KeyRoundIcon"
|
||||||
route="settings/keys"
|
route="settings/keys"
|
||||||
/>
|
/>
|
||||||
|
<PageCard
|
||||||
|
v-if="hasPermission('admin')"
|
||||||
|
:name="$t('title.settings.admin')"
|
||||||
|
:icon="WrenchIcon"
|
||||||
|
style="
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--element-color) 100%,
|
||||||
|
rgb(255, 90, 90) 40%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
route="settings/admin"
|
||||||
|
/>
|
||||||
<PageCard
|
<PageCard
|
||||||
:name="$t('title.settings.about')"
|
:name="$t('title.settings.about')"
|
||||||
:icon="InfoIcon"
|
:icon="InfoIcon"
|
||||||
|
148
src/views/settings/AdminSettings.vue
Normal file
148
src/views/settings/AdminSettings.vue
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<script setup>
|
||||||
|
import ExpandSection from "@/components/settings/expand-section.vue";
|
||||||
|
import TimetableCard from "@/components/settings/timetable-card.vue";
|
||||||
|
import { baseUrl } from "@/store";
|
||||||
|
import { PlusIcon, SaveIcon, XIcon } from "lucide-vue-next";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const timetables = ref([]);
|
||||||
|
async function fetchTimetables() {
|
||||||
|
const response = await fetch(baseUrl + "/admin/timetable");
|
||||||
|
if (response.status != 200) return;
|
||||||
|
timetables.value = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTimetable(id) {
|
||||||
|
const response = await fetch(
|
||||||
|
baseUrl + "/admin/timetable?id=" + encodeURIComponent(id),
|
||||||
|
{ method: "delete" }
|
||||||
|
);
|
||||||
|
if (response.status != 200) alert("Delete failed!");
|
||||||
|
fetchTimetables();
|
||||||
|
}
|
||||||
|
|
||||||
|
const editId = ref(-1);
|
||||||
|
const timetableName = ref();
|
||||||
|
const timetableClass = ref();
|
||||||
|
const timetableSource = ref();
|
||||||
|
const timetableTrusted = ref(true);
|
||||||
|
|
||||||
|
async function createTimetable() {
|
||||||
|
const response = await fetch(baseUrl + "/admin/timetable", {
|
||||||
|
method: "post",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: timetableName.value,
|
||||||
|
class: timetableClass.value,
|
||||||
|
source: timetableSource.value,
|
||||||
|
trusted: timetableTrusted.value,
|
||||||
|
data: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.status != 201) alert("Post failed!");
|
||||||
|
fetchTimetables();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTimetable() {
|
||||||
|
const response = await fetch(
|
||||||
|
baseUrl + "/admin/timetable?id=" + encodeURIComponent(editId.value),
|
||||||
|
{
|
||||||
|
method: "put",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: timetableName.value,
|
||||||
|
class: timetableClass.value,
|
||||||
|
source: timetableSource.value,
|
||||||
|
trusted: timetableTrusted.value,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.status != 201) alert("Post failed!");
|
||||||
|
fetchTimetables();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTimetables();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>Admin Settings</h1>
|
||||||
|
<ExpandSection title="Timetables">
|
||||||
|
<div class="list">
|
||||||
|
<div class="create_options">
|
||||||
|
<input type="text" placeholder="Name" v-model="timetableName" />
|
||||||
|
<input type="text" placeholder="Class" v-model="timetableClass" />
|
||||||
|
<input type="text" placeholder="Source" v-model="timetableSource" />
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" v-model="timetableTrusted" />
|
||||||
|
<span>Trusted</span>
|
||||||
|
</div>
|
||||||
|
<div class="button" v-if="editId == -1" @click="createTimetable()">
|
||||||
|
<PlusIcon />
|
||||||
|
<span>Create Timetable</span>
|
||||||
|
</div>
|
||||||
|
<div class="button" v-if="editId != -1" @click="updateTimetable()">
|
||||||
|
<SaveIcon />
|
||||||
|
<span>Save Timetable</span>
|
||||||
|
</div>
|
||||||
|
<div class="button" v-if="editId != -1" @click="editId = -1">
|
||||||
|
<XIcon />
|
||||||
|
<span>Cancel edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TimetableCard
|
||||||
|
v-for="timetable in timetables"
|
||||||
|
:key="timetable"
|
||||||
|
:timetable="timetable"
|
||||||
|
:editable="true"
|
||||||
|
:selected="timetable.id == editId"
|
||||||
|
:admin="true"
|
||||||
|
@delete="deleteTimetable(timetable.id)"
|
||||||
|
@edit="
|
||||||
|
() => {
|
||||||
|
editId = timetable.id;
|
||||||
|
timetableName = timetable.title;
|
||||||
|
timetableClass = timetable.class;
|
||||||
|
timetableSource = timetable.source;
|
||||||
|
timetableTrusted = timetable.trusted;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ExpandSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 {
|
||||||
|
margin: 0px 0px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background-color: var(--element-color);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create_options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
Reference in New Issue
Block a user