Frontend: Added forms to delete nodes.

This commit is contained in:
baldo 2022-08-23 17:05:13 +02:00
parent a0d186da3a
commit 3a2f0799eb
13 changed files with 531 additions and 13 deletions

View file

@ -78,4 +78,13 @@ input {
} }
} }
fieldset {
margin: $fieldset-margin;
padding: $fieldset-padding;
border: $fieldset-border;
border-radius: $fieldset-border-radius;
background-color: $fieldset-background-color;
}
</style> </style>

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
import ValidationForm from "@/components/form/ValidationForm.vue";
import {useNodeStore} from "@/stores/node";
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import ActionButton from "@/components/form/ActionButton.vue";
import NodePreviewCard from "@/components/nodes/NodePreviewCard.vue";
import type {Hostname, StoredNode} from "@/types";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import router, {route, RouteName} from "@/router";
import {computed, nextTick, ref} from "vue";
import {ApiError} from "@/utils/Api";
import ErrorCard from "@/components/ErrorCard.vue";
import {useConfigStore} from "@/stores/config";
interface Props {
node: StoredNode;
}
const props = defineProps<Props>();
const nodeStore = useNodeStore();
const configStore = useConfigStore();
const email = computed(() => configStore.getConfig?.community.contactEmail);
const errorDeletingNode = ref<boolean>(false);
const emit = defineEmits<{
(e: "delete", hostname: Hostname): void
}>();
async function onSubmit() {
errorDeletingNode.value = false;
// Make sure to re-render error message to trigger scrolling into view.
await nextTick();
const token = props.node.token;
try {
await nodeStore.deleteByToken(token);
} catch (error) {
if (error instanceof ApiError) {
// If the node has been deleted in the meantime, we move on as if no error had occured.
if (!error.isNotFoundError()) {
errorDeletingNode.value = true;
return;
}
} else {
throw error;
}
}
emit("delete", props.node.hostname);
}
async function onAbort() {
await router.push(route(RouteName.HOME));
}
</script>
<template>
<ValidationForm @submit="onSubmit">
<h2>Soll der Knoten wirklich gelöscht werden?</h2>
<p>
Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich endgültig gelöscht werden?
Du kannst ihn selbstverständlich später jederzeit erneut anmelden!
</p>
<ErrorCard v-if="errorDeletingNode">
Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses Problem
weiter bestehen, so wende dich bitte per E-Mail an <a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
</ErrorCard>
<NodePreviewCard class="preview" :node="props.node"/>
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.SMALL">
<ActionButton
type="submit"
icon="trash"
:variant="ComponentVariant.WARNING"
:size="ButtonSize.MEDIUM">
Knoten löschen
</ActionButton>
<ActionButton
type="reset"
icon="times"
:variant="ComponentVariant.SECONDARY"
:size="ButtonSize.MEDIUM"
@click="onAbort">
Abbrechen
</ActionButton>
</ButtonGroup>
</ValidationForm>
</template>
<style lang="scss" scoped>
.preview {
margin: 1em 0;
}
</style>

View file

@ -0,0 +1,122 @@
<script setup lang="ts">
import {useConfigStore} from "@/stores/config";
import {useNodeStore} from "@/stores/node";
import {computed, nextTick, ref} from "vue";
import CONSTRAINTS from "@/shared/validation/constraints";
import ActionButton from "@/components/form/ActionButton.vue";
import type {StoredNode, Token} from "@/types";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import ErrorCard from "@/components/ErrorCard.vue";
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import ValidationForm from "@/components/form/ValidationForm.vue";
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
import {route, RouteName} from "@/router";
import RouteButton from "@/components/form/RouteButton.vue";
import {ApiError} from "@/utils/Api";
const configStore = useConfigStore();
const email = computed(() => configStore.getConfig?.community.contactEmail);
const nodeStore = useNodeStore();
const emit = defineEmits<{
(e: "submit", node: StoredNode): void;
}>();
const token = ref("");
const nodeNotFound = ref<boolean>(false);
const generalError = ref<boolean>(false);
async function onSubmit() {
nodeNotFound.value = false;
generalError.value = false;
// Make sure to re-render error message to trigger scrolling into view.
await nextTick();
try {
const node = await nodeStore.fetchByToken(token.value as Token);
emit("submit", node);
} catch (error) {
if (error instanceof ApiError) {
if (error.isNotFoundError()) {
nodeNotFound.value = true;
} else {
console.error(error);
generalError.value = true;
}
} else {
throw error;
}
}
}
</script>
<template>
<ValidationForm novalidate="" ref="form" @submit="onSubmit">
<h2>Knoten löschen</h2>
<div>
<p>
Um die Daten Deines Knotens zu löschen, benötigen wir den passenden Token (eine 16-stellige Folge aus
Ziffern und Buchstaben). Diesen hast Du beim ersten Anmelden Deines Knotens erhalten. Sinn des Tokens
ist,
Dich davor zu schützen, dass Dritte unbefugt Deine Daten einsehen oder ändern können.
</p>
<p>
<strong>
Solltest Du den Token nicht mehr haben, wende Dich einfach per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
</strong>
</p>
<ErrorCard v-if="nodeNotFound">
Zum Token wurde kein passender Knoten gefunden.
</ErrorCard>
<ErrorCard v-if="generalError">
Beim Abrufen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses
Problem weiter bestehen, so wende dich bitte per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
</ErrorCard>
<fieldset>
<ValidationFormInput
v-model="token"
label="Token"
placeholder="Dein 16-stelliger Token"
:constraint="CONSTRAINTS.token"
validation-error="Das Token ist ein 16-stelliger Wert bestehend aus 0-9 und a-f."
/>
<ButtonGroup :align="ComponentAlignment.RIGHT" :button-size="ButtonSize.SMALL">
<ActionButton
type="submit"
icon="trash"
:variant="ComponentVariant.WARNING"
:size="ButtonSize.SMALL">
Knoten löschen
</ActionButton>
<RouteButton
type="reset"
icon="times"
:variant="ComponentVariant.SECONDARY"
:size="ButtonSize.SMALL"
:route="route(RouteName.HOME)">
Abbrechen
</RouteButton>
</ButtonGroup>
</fieldset>
<p>
<em>
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin in der Knotenkarte angezeigt werden. Dies
ist dann der Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines anderen aktiven Knotens
steht. Die angezeigten Daten sind dann die während der Einrichtung des Knotens im Config-Mode
(Konfigurationsoberfläche des Routers) hinterlegten.
</em>
</p>
</div>
</ValidationForm>
</template>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import type {Hostname} from "@/types";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import {useConfigStore} from "@/stores/config";
import {computed} from "vue";
import RouteButton from "@/components/form/RouteButton.vue";
import {route, RouteName} from "@/router";
const configStore = useConfigStore();
const email = computed(() => configStore.getConfig?.community.contactEmail);
const mapUrl = computed(() => configStore.getConfig?.map.mapUrl);
interface Props {
hostname: Hostname;
}
const props = defineProps<Props>();
</script>
<template>
<div>
<h1>Erledigt!</h1>
<p>
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann jetzt noch bis zu 20 Minuten dauern,
bis die Änderungen überall wirksam werden und sich in der
<a :href="configStore.getConfig?.map.mapUrl" target="_blank">Knotenkarte</a> auswirken.
</p>
<div class="summary">
<div class="deleted-node">
<i class="fa fa-trash-o" aria-hidden="true"/> <span class="node">{{ hostname }}</span>
</div>
</div>
<p>
<em>
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin als online in der Knotenkarte angezeigt werden.
Dies ist dann der Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines anderen aktiven
Knotens steht. Die angezeigten Daten sind dann die während der Einrichtung des Knotens im Config-Mode
(Konfigurationsoberfläche des Routers) hinterlegten. Außerdem kann es nach dem Löschen noch etwa einen
Monat dauern, bis der Knoten in der Karte auch nicht mehr als offline erscheint.
</em>
</p>
<p>
Bei Fragen wende Dich gerne an
<a :href="`mailto:${ email }`">{{ email }}</a>.
</p>
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.MEDIUM">
<RouteButton
icon="reply"
:variant="ComponentVariant.SECONDARY"
:size="ButtonSize.MEDIUM"
:route="route(RouteName.HOME)">
Zurück zum Anfang
</RouteButton>
</ButtonGroup>
</div>
</template>
<style lang="scss" scoped>
@import "../../scss/variables";
.summary {
text-align: center;
.deleted-node {
display: inline-block;
padding: $node-deleted-summary-padding;
border: $node-deleted-summary-border;
border-radius: $node-deleted-summary-border-radius;
font-size: $node-deleted-summary-font-size;
}
}
</style>

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
import type {StoredNode} from "@/types";
import {MonitoringState} from "@/types";
interface Props {
node: StoredNode;
}
const props = defineProps<Props>();
</script>
<template>
<div class="node-preview-card">
<h3>
<i class="fa fa-dot-circle-o" aria-hidden="true" title="Knotenname:" />
{{ node.hostname }}
</h3>
<div class="field">
<strong>Token:</strong> <code>{{ node.token }}</code>
</div>
<div class="field">
<i class="fa fa-map-marker" aria-hidden="true" title="Standort:" />
{{ node.coords || "nicht angegeben" }}
</div>
<div class="field">
<strong>MAC-Adresse:</strong> <code>{{ node.mac }}</code>
</div>
<div class="field">
<strong>VPN-Schlüssel:</strong> <code>{{ node.key || "nicht angegeben" }}</code>
</div>
<div class="field">
<strong>Monitoring:</strong>
<span v-if="node.monitoringState === MonitoringState.PENDING">
Bestätigung ausstehend
</span>
<span v-if="node.monitoringState === MonitoringState.ACTIVE">
aktiv
</span>
<span v-if="node.monitoringState === MonitoringState.DISABLED">
nicht aktiv
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "../../scss/variables";
.node-preview-card {
display: flex;
flex-direction: column;
align-items: center;
padding: $node-preview-card-padding;
border: $node-preview-card-border;
border-radius: $node-preview-card-border-radius;
h3 {
font-size: $node-preview-card-headline-font-size;
margin: $node-preview-card-headline-margin;
text-align: center;
}
.field {
margin: $node-preview-card-field-margin;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
code {
font-size: $node-preview-card-field-code-font-size;
}
}
</style>

View file

@ -2,6 +2,7 @@ import {createRouter, createWebHistory, type LocationQueryRaw} from "vue-router"
import AdminDashboardView from "@/views/AdminDashboardView.vue"; import AdminDashboardView from "@/views/AdminDashboardView.vue";
import AdminNodesView from "@/views/AdminNodesView.vue"; import AdminNodesView from "@/views/AdminNodesView.vue";
import HomeView from "@/views/HomeView.vue"; import HomeView from "@/views/HomeView.vue";
import NodeDeleteView from "@/views/NodeDeleteView.vue";
import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types"; import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types";
export interface Route { export interface Route {
@ -11,6 +12,7 @@ export interface Route {
export enum RouteName { export enum RouteName {
HOME = "home", HOME = "home",
NODE_DELETE = "node-delete",
ADMIN = "admin", ADMIN = "admin",
ADMIN_NODES = "admin-nodes", ADMIN_NODES = "admin-nodes",
} }
@ -30,6 +32,11 @@ const router = createRouter({
name: RouteName.HOME, name: RouteName.HOME,
component: HomeView, component: HomeView,
}, },
{
path: "/node/delete",
name: RouteName.NODE_DELETE,
component: NodeDeleteView,
},
{ {
path: "/admin", path: "/admin",
name: RouteName.ADMIN, name: RouteName.ADMIN,

View file

@ -79,6 +79,8 @@ $link-hover-color: $variant-color-warning;
// Form // Form
$label-font-weight: 600; $label-font-weight: 600;
$fieldset-margin: 0;
$fieldset-padding: 0.5em 0.75em;
$fieldset-border: 0.1em solid $gray; $fieldset-border: 0.1em solid $gray;
$fieldset-border-radius: 0.5em; $fieldset-border-radius: 0.5em;
$fieldset-background-color: $gray-darker; $fieldset-background-color: $gray-darker;
@ -114,6 +116,20 @@ $error-card-font-weight: 600;
$error-card-background-color: lighten($variant-color-danger, 10%); $error-card-background-color: lighten($variant-color-danger, 10%);
$error-card-color: $page-background-color; $error-card-color: $page-background-color;
// Node preview
$node-preview-card-padding: 0.75em 1em;
$node-preview-card-border: 0.2em solid $gray;
$node-preview-card-border-radius: 1em;
$node-preview-card-headline-font-size: 1.5em;
$node-preview-card-headline-margin: 0 0 0.5em;
$node-preview-card-field-margin: 0.25em 0;
$node-preview-card-field-code-font-size: 1.25em;
// Deleted node summary
$node-deleted-summary-padding: $node-preview-card-padding;
$node-deleted-summary-border: $node-preview-card-border;
$node-deleted-summary-border-radius: $node-preview-card-border-radius;
$node-deleted-summary-font-size: 1.25em;
// Navigation // Navigation
$nav-bar-background-color: $gray-dark; $nav-bar-background-color: $gray-dark;

View file

@ -0,0 +1,23 @@
import {defineStore} from "pinia";
import {isStoredNode, type StoredNode, type Token} from "@/types";
import {api} from "@/utils/Api";
interface NodeStoreState {
}
export const useNodeStore = defineStore({
id: "node",
state(): NodeStoreState {
return {};
},
getters: {},
actions: {
async fetchByToken(token: Token): Promise<StoredNode> {
return await api.get(`node/${token}`, isStoredNode);
},
async deleteByToken(token: Token): Promise<void> {
await api.delete(`node/${token}`);
}
},
});

View file

@ -4,6 +4,55 @@ import {parseInteger} from "@/utils/Numbers";
type Method = "GET" | "PUT" | "POST" | "DELETE"; type Method = "GET" | "PUT" | "POST" | "DELETE";
enum ApiErrorType {
REQUEST_FAILED = "request_failed",
UNEXPECTED_RESULT_TYPE = "unexpected_result_type",
}
enum HttpStatusCode {
NOT_FOUND = 404,
}
export class ApiError extends Error {
private constructor(
message: string,
private status: number,
private errorType: ApiErrorType,
) {
super(message);
}
static async requestFailed(
method: Method,
path: string,
response: Response,
): Promise<ApiError> {
const body = await response.text();
return new ApiError(
`API ${method} request failed: ${path} => ${response.status} - ${body}`,
response.status,
ApiErrorType.REQUEST_FAILED,
);
}
static async unexpectedResultType(
method: Method,
path: string,
response: Response,
json: any,
): Promise<ApiError> {
return new ApiError(
`API ${method} request result has unexpected type. ${path} => ${json}`,
response.status,
ApiErrorType.UNEXPECTED_RESULT_TYPE,
);
}
isNotFoundError(): boolean {
return this.errorType === ApiErrorType.REQUEST_FAILED && this.status === HttpStatusCode.NOT_FOUND;
}
}
interface PagedListResult<T> { interface PagedListResult<T> {
entries: T[]; entries: T[];
total: number; total: number;
@ -47,15 +96,14 @@ class Api {
}); });
if (!response.ok) { if (!response.ok) {
const body = await response.text(); throw await ApiError.requestFailed(method, path, response);
throw new Error(`API ${method} request failed: ${path} => ${response.status} - ${body}`);
} }
if (isT) { if (isT) {
const json = await response.json(); const json = await response.json();
if (isT && !isT(json)) { if (isT && !isT(json)) {
console.log(json); console.log(json);
throw new Error(`API ${method} request result has wrong type. ${path} => ${json}`); throw await ApiError.unexpectedResultType(method, path, response, json);
} }
return { return {

View file

@ -3,7 +3,8 @@ import ActionButton from "@/components/form/ActionButton.vue";
import ButtonGroup from "@/components/form/ButtonGroup.vue"; import ButtonGroup from "@/components/form/ButtonGroup.vue";
import PageContainer from "@/components/page/PageContainer.vue"; import PageContainer from "@/components/page/PageContainer.vue";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types"; import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
</script> import {route, RouteName} from "@/router";
import RouteButton from "@/components/form/RouteButton.vue";</script>
<template> <template>
<PageContainer> <PageContainer>
@ -28,12 +29,13 @@ import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
icon="pencil"> icon="pencil">
Knotendaten ändern Knotendaten ändern
</ActionButton> </ActionButton>
<ActionButton <RouteButton
:variant="ComponentVariant.WARNING" :variant="ComponentVariant.WARNING"
:size="ButtonSize.LARGE" :size="ButtonSize.LARGE"
icon="trash"> icon="trash"
:route="route(RouteName.NODE_DELETE)">
Knoten löschen Knoten löschen
</ActionButton> </RouteButton>
</ButtonGroup> </ButtonGroup>
</PageContainer> </PageContainer>
</template> </template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import PageContainer from "@/components/page/PageContainer.vue";
import NodeDeleteForm from "@/components/nodes/NodeDeleteForm.vue";
import NodeDeleteConfirmationForm from "@/components/nodes/NodeDeleteConfirmationForm.vue";
import NodeDeletedPanel from "@/components/nodes/NodeDeletedPanel.vue";
import {ref} from "vue";
import type {Hostname, StoredNode} from "@/types";
const node = ref<StoredNode | undefined>(undefined);
const deletedHostname = ref<Hostname | undefined>(undefined);
async function onSelectNode(nodeToDelete: StoredNode) {
node.value = nodeToDelete;
}
async function onDelete(hostname: Hostname) {
deletedHostname.value = hostname;
}
</script>
<template>
<PageContainer>
<NodeDeleteForm v-if="!node && !deletedHostname" @submit="onSelectNode"/>
<NodeDeleteConfirmationForm v-if="node && !deletedHostname" @delete="onDelete" :node="node"/>
<NodeDeletedPanel v-if="deletedHostname" :hostname="deletedHostname"/>
</PageContainer>
</template>
<style lang="scss" scoped>
</style>

View file

@ -3,14 +3,14 @@
// noinspection RegExpSimplifiable // noinspection RegExpSimplifiable
const CONSTRAINTS = { const CONSTRAINTS = {
id:{ id: {
type: 'string', type: 'string',
regex: /^[1-9][0-9]*$/, regex: /^[1-9][0-9]*$/,
optional: false optional: false
}, },
token:{ token: {
type: 'string', type: 'string',
regex: /^[0-9a-f]{16}$/i, regex: /^[0-9a-fA-F]{16}$/,
optional: false optional: false
}, },
node: { node: {

View file

@ -5,14 +5,14 @@
(function () { (function () {
var constraints = { var constraints = {
id:{ id: {
type: 'string', type: 'string',
regex: /^[1-9][0-9]*$/, regex: /^[1-9][0-9]*$/,
optional: false optional: false
}, },
token:{ token: {
type: 'string', type: 'string',
regex: /^[0-9a-f]{16}$/i, regex: /^[0-9a-fA-F]{16}$/,
optional: false optional: false
}, },
node: { node: {