Basic node registration form.
This commit is contained in:
parent
fb73dac224
commit
fb5bf934ff
12 changed files with 549 additions and 40 deletions
frontend/src/components
40
frontend/src/components/ExpandableHelpBox.vue
Normal file
40
frontend/src/components/ExpandableHelpBox.vue
Normal file
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const expanded = ref(false);
|
||||
|
||||
function toggleExpansion() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i
|
||||
class="fa fa-question-circle help-icon"
|
||||
@click.prevent="toggleExpansion"
|
||||
aria-hidden="true"
|
||||
title="Hilfe"
|
||||
/>
|
||||
<p v-if="expanded" class="help-text">
|
||||
{{ text }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../scss/variables";
|
||||
|
||||
.help-icon {
|
||||
color: $help-icon-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: $help-text-margin;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { getCurrentInstance, onMounted, ref } from "vue";
|
||||
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
||||
import { type Constraint, forConstraint } from "@/shared/validation/validator";
|
||||
import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue";
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
|
@ -9,6 +10,7 @@ interface Props {
|
|||
placeholder: string;
|
||||
constraint: Constraint;
|
||||
validationError: string;
|
||||
help?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
@ -16,6 +18,10 @@ const emit = defineEmits<{
|
|||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayLabel = computed(() =>
|
||||
props.constraint.optional ? props.label : `${props.label}*`
|
||||
);
|
||||
|
||||
const input = ref<HTMLInputElement>();
|
||||
const valid = ref(true);
|
||||
const validated = ref(false);
|
||||
|
@ -68,19 +74,20 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="validation-form-input">
|
||||
<label>
|
||||
{{ label }}:
|
||||
{{ displayLabel }}:
|
||||
<ExpandableHelpBox v-if="props.help" :text="props.help" />
|
||||
<input
|
||||
ref="input"
|
||||
:value="modelValue"
|
||||
:value="props.modelValue"
|
||||
@input="onInput"
|
||||
:type="type || 'text'"
|
||||
:placeholder="placeholder"
|
||||
:type="props.type || 'text'"
|
||||
:placeholder="props.placeholder"
|
||||
/>
|
||||
</label>
|
||||
<div class="validation-error" v-if="!valid">
|
||||
{{ validationError }}
|
||||
{{ props.validationError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -88,6 +95,10 @@ onMounted(() => {
|
|||
<style scoped lang="scss">
|
||||
@import "../../scss/variables";
|
||||
|
||||
.validation-form-input {
|
||||
margin: $validation-form-input-margin;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: $label-font-weight;
|
||||
|
|
212
frontend/src/components/nodes/NodeCreateForm.vue
Normal file
212
frontend/src/components/nodes/NodeCreateForm.vue
Normal file
|
@ -0,0 +1,212 @@
|
|||
<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 {
|
||||
Coordinates,
|
||||
EmailAddress,
|
||||
FastdKey,
|
||||
Hostname,
|
||||
MAC,
|
||||
Nickname,
|
||||
StoredNode,
|
||||
} from "@/types";
|
||||
import {
|
||||
ButtonSize,
|
||||
ComponentAlignment,
|
||||
ComponentVariant,
|
||||
hasOwnProperty,
|
||||
} 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 nodeStore = useNodeStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "create", node: StoredNode): void;
|
||||
}>();
|
||||
|
||||
const generalError = ref<boolean>(false);
|
||||
|
||||
const CONFLICT_MESSAGES: Record<string, string> = {
|
||||
hostname: "Der Knotenname ist bereits vergeben. Bitte wähle einen anderen.",
|
||||
key: "Für den VPN-Schlüssel gibt es bereits einen Eintrag.",
|
||||
mac: "Für die MAC-Adresse gibt es bereits einen Eintrag.",
|
||||
};
|
||||
|
||||
const conflictErrorMessage = ref<string | undefined>(undefined);
|
||||
|
||||
const hostname = ref("" as Hostname);
|
||||
const fastdKey = ref("" as FastdKey);
|
||||
const mac = ref("" as MAC);
|
||||
const coords = ref("" as Coordinates);
|
||||
const nickname = ref("" as Nickname);
|
||||
const email = ref("" as EmailAddress);
|
||||
const monitoring = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
generalError.value = false;
|
||||
conflictErrorMessage.value = undefined;
|
||||
|
||||
// Make sure to re-render error message to trigger scrolling into view.
|
||||
await nextTick();
|
||||
|
||||
try {
|
||||
const node = await nodeStore.create({
|
||||
hostname: hostname.value,
|
||||
key: fastdKey.value || undefined,
|
||||
mac: mac.value,
|
||||
coords: coords.value || undefined,
|
||||
nickname: nickname.value,
|
||||
email: email.value,
|
||||
monitoring: monitoring.value,
|
||||
});
|
||||
emit("create", node);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
console.error(error);
|
||||
|
||||
const conflictingField = error.getConflictField();
|
||||
if (
|
||||
conflictingField !== undefined &&
|
||||
hasOwnProperty(CONFLICT_MESSAGES, conflictingField)
|
||||
) {
|
||||
conflictErrorMessage.value =
|
||||
CONFLICT_MESSAGES[conflictingField];
|
||||
} else {
|
||||
generalError.value = true;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ValidationForm novalidate="" ref="form" @submit="onSubmit">
|
||||
<h2>Neuen Knoten anmelden</h2>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
Damit Dein neuer Freifunk-Router erfolgreich ins Netz
|
||||
eingebunden werden kann, benötigen wir noch ein paar angaben von
|
||||
Dir. Sobald Du fertig bist, kannst Du durch einen Klick auf
|
||||
"Knoten anmelden" die Anmeldung abschließen.
|
||||
</p>
|
||||
<p>
|
||||
Und keine Sorge:
|
||||
<strong>Datenschutz ist uns genauso wichtig wie Dir.</strong>
|
||||
</p>
|
||||
|
||||
<ErrorCard v-if="conflictErrorMessage">{{
|
||||
conflictErrorMessage
|
||||
}}</ErrorCard>
|
||||
<ErrorCard v-if="generalError">
|
||||
Beim Anlegen 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 :href="`mailto:${email}`">{{ email }}</a
|
||||
>.
|
||||
</ErrorCard>
|
||||
|
||||
<fieldset>
|
||||
<h3>Knotendaten</h3>
|
||||
|
||||
<ValidationFormInput
|
||||
v-model="hostname"
|
||||
label="Knotenname"
|
||||
placeholder="z. B. Lisas-Freifunk"
|
||||
:constraint="CONSTRAINTS.node.hostname"
|
||||
help="Das ist der Name, der auch auf der Karte auftaucht."
|
||||
validation-error="Knotennamen dürfen maximal 32 Zeichen lang sein und nur Klein- und Großbuchstaben, sowie Ziffern, - und _ enthalten."
|
||||
/>
|
||||
<ValidationFormInput
|
||||
v-model="fastdKey"
|
||||
label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)"
|
||||
placeholder="Dein 64-stelliger VPN-Schlüssel"
|
||||
:constraint="CONSTRAINTS.node.key"
|
||||
help="Dieser Schlüssel wird verwendet, um die Verbindung Deines Routers zu den Gateway-Servern abzusichern."
|
||||
validation-error="Knotennamen dürfen maximal 32 Zeichen lang sein und nur Klein- und Großbuchstaben, sowie Ziffern, - und _ enthalten."
|
||||
/>
|
||||
<ValidationFormInput
|
||||
v-model="mac"
|
||||
label="MAC-Adresse"
|
||||
placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc"
|
||||
:constraint="CONSTRAINTS.node.mac"
|
||||
help="Die MAC-Adresse (kurz „MAC“) steht üblicherweise auf dem Aufkleber auf der Unterseite deines Routers. Sie wird verwendet, um die Daten Deines Routers auf der Karte korrekt zuzuordnen."
|
||||
validation-error="Die angegebene MAC-Adresse ist ungültig."
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<h1>TODO: Standort</h1>
|
||||
|
||||
<fieldset>
|
||||
<h3>Wie können wir Dich erreichen?</h3>
|
||||
|
||||
<p class="help-block">
|
||||
Deinen Namen und Deine E-Mail-Adresse verwenden wir
|
||||
ausschließlich, um bei Problemen mit Deinem Router oder bei
|
||||
wichtigen Änderungen Kontakt zu Dir aufzunehmen. Bitte trage
|
||||
eine gültige E-Mail-Adresse ein, damit wir Dich im Zweifel
|
||||
erreichen können. Deine persönlichen Daten sind
|
||||
selbstverständlich
|
||||
<strong>nicht öffentlich einsehbar</strong> und werden von
|
||||
uns <strong>nicht weitergegeben</strong>
|
||||
oder anderweitig verwendet. Versprochen!
|
||||
</p>
|
||||
|
||||
<ValidationFormInput
|
||||
v-model="nickname"
|
||||
label="Nickname / Name"
|
||||
placeholder="z. B. Lisa"
|
||||
:constraint="CONSTRAINTS.node.nickname"
|
||||
validation-error="Nicknames dürfen maximal 64 Zeichen lang sein und nur Klein- und Großbuchstaben, sowie Ziffern, - und _ enthalten. Umlaute sind erlaubt."
|
||||
/>
|
||||
<ValidationFormInput
|
||||
v-model="email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
:placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`"
|
||||
:constraint="CONSTRAINTS.node.email"
|
||||
validation-error="Die angegebene E-Mail-Adresse ist ungültig."
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<h1>TODO: Monitoring</h1>
|
||||
|
||||
<ButtonGroup
|
||||
:align="ComponentAlignment.RIGHT"
|
||||
:button-size="ButtonSize.SMALL"
|
||||
>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
icon="dot-circle-o"
|
||||
:variant="ComponentVariant.INFO"
|
||||
:size="ButtonSize.SMALL"
|
||||
>
|
||||
Knoten anmelden
|
||||
</ActionButton>
|
||||
<RouteButton
|
||||
type="reset"
|
||||
icon="times"
|
||||
:variant="ComponentVariant.SECONDARY"
|
||||
:size="ButtonSize.SMALL"
|
||||
:route="route(RouteName.HOME)"
|
||||
>
|
||||
Abbrechen
|
||||
</RouteButton>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</ValidationForm>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
103
frontend/src/components/nodes/NodeCreatedPanel.vue
Normal file
103
frontend/src/components/nodes/NodeCreatedPanel.vue
Normal file
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||
import type { Hostname, StoredNode } 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";
|
||||
import NodePreviewCard from "@/components/nodes/NodePreviewCard.vue";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const email = computed(() => configStore.getConfig.community.contactEmail);
|
||||
const mapUrl = computed(() => configStore.getConfig.map.mapUrl);
|
||||
|
||||
interface Props {
|
||||
node: StoredNode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2>Geschafft!</h2>
|
||||
<p>
|
||||
Dein Freifunk-Knoten ist erfolgreich angemeldet worden. Es kann
|
||||
jetzt noch bis zu 20 Minuten dauern, bis Dein Knoten funktioniert
|
||||
und in der
|
||||
<a :href="mapUrl" target="_blank">Knotenkarte</a> auftaucht.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Du kannst die Daten Deines Knotens später selber ändern. Dazu
|
||||
benötigst Du das Token unten (16-stelliger Code).
|
||||
</p>
|
||||
|
||||
<p class="token-hint">Notiere Dir bitte folgendes Token:</p>
|
||||
|
||||
<div class="summary">
|
||||
<span class="token">
|
||||
<i class="fa fa-pencil"></i>
|
||||
{{ props.node.token }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>Hinweis:</strong>
|
||||
Sollte Dein Knoten länger als drei Monate offline sein, so wird
|
||||
dieser nach einer gewissen Zeit automatisch gelöscht. Du kannst
|
||||
Deinen Knoten selbstverständlich jederzeit neu anmelden.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Bei Fragen wende Dich gerne an
|
||||
<a :href="`mailto:${email}`">{{ email }}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
<NodePreviewCard class="preview" :node="props.node" />
|
||||
|
||||
<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;
|
||||
|
||||
.token {
|
||||
display: inline-block;
|
||||
|
||||
padding: $node-created-summary-padding;
|
||||
|
||||
background-color: $node-created-summary-background-color;
|
||||
|
||||
border: $node-created-summary-border;
|
||||
border-radius: $node-created-summary-border-radius;
|
||||
|
||||
font-size: $node-created-summary-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: {
|
||||
top: 1.5em;
|
||||
bottom: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -23,7 +23,7 @@ const emit = defineEmits<{
|
|||
(e: "submit", node: StoredNode): void;
|
||||
}>();
|
||||
|
||||
const token = ref("");
|
||||
const token = ref("" as Token);
|
||||
const nodeNotFound = ref<boolean>(false);
|
||||
const generalError = ref<boolean>(false);
|
||||
|
||||
|
@ -34,7 +34,7 @@ async function onSubmit() {
|
|||
await nextTick();
|
||||
|
||||
try {
|
||||
const node = await nodeStore.fetchByToken(token.value as Token);
|
||||
const node = await nodeStore.fetchByToken(token.value);
|
||||
emit("submit", node);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
|
@ -86,6 +86,7 @@ async function onSubmit() {
|
|||
|
||||
<fieldset>
|
||||
<ValidationFormInput
|
||||
class="token-input"
|
||||
v-model="token"
|
||||
label="Token"
|
||||
placeholder="Dein 16-stelliger Token"
|
||||
|
@ -130,4 +131,8 @@ async function onSubmit() {
|
|||
</ValidationForm>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
.token-input {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,7 +20,7 @@ const props = defineProps<Props>();
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Erledigt!</h1>
|
||||
<h2>Erledigt!</h2>
|
||||
<p>
|
||||
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann
|
||||
jetzt noch bis zu 20 Minuten dauern, bis die Änderungen überall
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue