Frontend: Added forms to delete nodes.
This commit is contained in:
parent
a0d186da3a
commit
3a2f0799eb
|
@ -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>
|
||||
|
|
99
frontend/src/components/nodes/NodeDeleteConfirmationForm.vue
Normal file
99
frontend/src/components/nodes/NodeDeleteConfirmationForm.vue
Normal 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>
|
122
frontend/src/components/nodes/NodeDeleteForm.vue
Normal file
122
frontend/src/components/nodes/NodeDeleteForm.vue
Normal 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>
|
80
frontend/src/components/nodes/NodeDeletedPanel.vue
Normal file
80
frontend/src/components/nodes/NodeDeletedPanel.vue
Normal 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>
|
82
frontend/src/components/nodes/NodePreviewCard.vue
Normal file
82
frontend/src/components/nodes/NodePreviewCard.vue
Normal 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>
|
|
@ -2,6 +2,7 @@ import {createRouter, createWebHistory, type LocationQueryRaw} from "vue-router"
|
|||
import AdminDashboardView from "@/views/AdminDashboardView.vue";
|
||||
import AdminNodesView from "@/views/AdminNodesView.vue";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import NodeDeleteView from "@/views/NodeDeleteView.vue";
|
||||
import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types";
|
||||
|
||||
export interface Route {
|
||||
|
@ -11,6 +12,7 @@ export interface Route {
|
|||
|
||||
export enum RouteName {
|
||||
HOME = "home",
|
||||
NODE_DELETE = "node-delete",
|
||||
ADMIN = "admin",
|
||||
ADMIN_NODES = "admin-nodes",
|
||||
}
|
||||
|
@ -30,6 +32,11 @@ const router = createRouter({
|
|||
name: RouteName.HOME,
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/node/delete",
|
||||
name: RouteName.NODE_DELETE,
|
||||
component: NodeDeleteView,
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: RouteName.ADMIN,
|
||||
|
|
|
@ -79,6 +79,8 @@ $link-hover-color: $variant-color-warning;
|
|||
|
||||
// Form
|
||||
$label-font-weight: 600;
|
||||
$fieldset-margin: 0;
|
||||
$fieldset-padding: 0.5em 0.75em;
|
||||
$fieldset-border: 0.1em solid $gray;
|
||||
$fieldset-border-radius: 0.5em;
|
||||
$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-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
|
||||
$nav-bar-background-color: $gray-dark;
|
||||
|
|
23
frontend/src/stores/node.ts
Normal file
23
frontend/src/stores/node.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
});
|
|
@ -4,6 +4,55 @@ import {parseInteger} from "@/utils/Numbers";
|
|||
|
||||
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> {
|
||||
entries: T[];
|
||||
total: number;
|
||||
|
@ -47,15 +96,14 @@ class Api {
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`API ${method} request failed: ${path} => ${response.status} - ${body}`);
|
||||
throw await ApiError.requestFailed(method, path, response);
|
||||
}
|
||||
|
||||
if (isT) {
|
||||
const json = await response.json();
|
||||
if (isT && !isT(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 {
|
||||
|
|
|
@ -3,7 +3,8 @@ import ActionButton from "@/components/form/ActionButton.vue";
|
|||
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||
import PageContainer from "@/components/page/PageContainer.vue";
|
||||
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||
</script>
|
||||
import {route, RouteName} from "@/router";
|
||||
import RouteButton from "@/components/form/RouteButton.vue";</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
|
@ -28,12 +29,13 @@ import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
|||
icon="pencil">
|
||||
Knotendaten ändern
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
<RouteButton
|
||||
:variant="ComponentVariant.WARNING"
|
||||
:size="ButtonSize.LARGE"
|
||||
icon="trash">
|
||||
icon="trash"
|
||||
:route="route(RouteName.NODE_DELETE)">
|
||||
Knoten löschen
|
||||
</ActionButton>
|
||||
</RouteButton>
|
||||
</ButtonGroup>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
|
30
frontend/src/views/NodeDeleteView.vue
Normal file
30
frontend/src/views/NodeDeleteView.vue
Normal 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>
|
|
@ -3,14 +3,14 @@
|
|||
|
||||
// noinspection RegExpSimplifiable
|
||||
const CONSTRAINTS = {
|
||||
id:{
|
||||
id: {
|
||||
type: 'string',
|
||||
regex: /^[1-9][0-9]*$/,
|
||||
optional: false
|
||||
},
|
||||
token:{
|
||||
token: {
|
||||
type: 'string',
|
||||
regex: /^[0-9a-f]{16}$/i,
|
||||
regex: /^[0-9a-fA-F]{16}$/,
|
||||
optional: false
|
||||
},
|
||||
node: {
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
|
||||
(function () {
|
||||
var constraints = {
|
||||
id:{
|
||||
id: {
|
||||
type: 'string',
|
||||
regex: /^[1-9][0-9]*$/,
|
||||
optional: false
|
||||
},
|
||||
token:{
|
||||
token: {
|
||||
type: 'string',
|
||||
regex: /^[0-9a-f]{16}$/i,
|
||||
regex: /^[0-9a-fA-F]{16}$/,
|
||||
optional: false
|
||||
},
|
||||
node: {
|
||||
|
|
Loading…
Reference in a new issue