Setup wizard for Settings

This commit is contained in:
Julian Krauser 2025-04-25 12:13:02 +02:00
parent 5d9007f517
commit 8880af2880
11 changed files with 622 additions and 60 deletions

View file

@ -0,0 +1,48 @@
<template>
<div class="w-full flex flex-row items-center">
<div class="contents" v-for="(i, index) in total" :key="index">
<SuccessCheckmark v-if="index <= successfull && index != step" class="h-8!" />
<div
v-else-if="index <= step"
class="flex items-center justify-center h-8 w-8 border-4 border-success rounded-full"
>
<div class="h-2 w-2 border-4 border-success bg-success rounded-full"></div>
</div>
<div v-else class="h-8 w-8 border-4 border-gray-400 rounded-full"></div>
<div v-if="i != total" class="grow border-2" :class="index < step ? ' border-success' : 'border-gray-400'"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import SuccessCheckmark from "./SuccessCheckmark.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
step: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
successfull: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
total: {
type: Number,
default: 1,
validator(value: number) {
return value >= 1;
},
},
},
});
</script>

View file

@ -1,10 +1,11 @@
<template>
<div class="flex flex-col text-gray-400 text-sm mt-4 items-center">
<div class="flex flex-row gap-2 justify-center">
<p v-if="appCustom_login_message">{{ appCustom_login_message }}</p>
<div class="flex flex-row gap-2 justify-center mb-3">
<a v-if="clubWebsite" :href="clubWebsite" target="_blank">Webseite</a>
<a v-if="clubImprint" :href="clubImprint" target="_blank">Datenschutz</a>
<a v-if="clubPrivacy" :href="clubPrivacy" target="_blank">Impressum</a>
</div>
<p v-if="appCustom_login_message">{{ appCustom_login_message }}</p>
<p>
<a href="https://ff-admin.de/admin" target="_blank">FF Admin</a>
entwickelt von

View file

@ -0,0 +1,70 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Admin Account</p>
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
</div>
<div>
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
</div>
<div>
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Admin-Account anlegen
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["createAdmin"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.createAdmin({
username: formData.username.value,
mail: formData.mail.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,66 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">App Konfiguration</p>
<div class="-space-y-px">
<div>
<input id="login_message" name="login_message" type="text" placeholder="Nachricht unter Login (optional)" />
</div>
<div class="flex flex-row items-center gap-2 pt-1">
<input type="checkbox" id="show_cal_link" checked />
<label for="show_cal_link">Link zum Kalender anzeigen (optional)</label>
</div>
</div>
<p class="text-primary cursor-pointer ml-auto" @click="skip('app')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Anwendungsdaten speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setApp", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setApp({
login_message: formData.login_message.value,
show_cal_link: formData.show_cal_link.checked,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,99 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Feuerwehr-/Vereinsdaten</p>
<div class="-space-y-px">
<div>
<input
id="name"
name="name"
type="text"
placeholder="Feuerwehr-/Vereinsname (optional)"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="imprint"
name="imprint"
type="url"
placeholder="Link zum Impressum (optional)"
class="rounded-none!"
/>
</div>
<div>
<input
id="privacy"
name="privacy"
type="url"
placeholder="Link zur Datenschutzerklärung (optional)"
class="rounded-none!"
/>
</div>
<div>
<input
id="website"
name="website"
type="url"
placeholder="Link zur Webseite (optional)"
class="rounded-t-none!"
/>
</div>
</div>
<p class="text-primary cursor-pointer ml-auto" @click="skip('club')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Vereinsdaten speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setClub", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setClub({
name: formData.name.value,
imprint: formData.imprint.value,
privacy: formData.privacy.value,
website: formData.website.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,3 @@
<template>
<p class="text-center">Sie haben einen Verifizierungslink per Mail erhalten.</p>
</template>

View file

@ -0,0 +1,87 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Feuerwehr-/Vereins-Auftritt</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<p>quadratisches Icon für App (optional)</p>
<img ref="icon_img" class="hidden w-full h-20 object-contain" />
<input class="hidden!" type="file" ref="icon" accept="image/*" @change="previewImage('icon')" />
<button type="button" primary-outline @click="($refs.icon as HTMLInputElement).click()">Icon auswählen</button>
</div>
<div class="flex flex-col gap-2">
<p>Logo (optional)</p>
<img ref="logo_img" class="hidden w-full h-20 object-contain" />
<input class="hidden!" type="file" ref="logo" accept="image/*" @change="previewImage('logo')" />
<button type="button" primary-outline @click="($refs.logo as HTMLInputElement).click()">Logo auswählen</button>
</div>
</div>
<br />
<p class="text-primary cursor-pointer ml-auto" @click="skip('appImages')">überspringen</p>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Bilder speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setClubImages", "skip"]),
previewImage(inputname: "icon" | "logo") {
let input = this.$refs[inputname] as HTMLInputElement;
let previewElement = this.$refs[inputname + "_img"] as HTMLImageElement;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewElement.src = e.target?.result as string;
previewElement.style.display = "block";
};
reader.readAsDataURL(input.files[0]);
} else {
previewElement.src = "";
previewElement.style.display = "none";
}
},
setup(e: any) {
this.setupStatus = "loading";
this.setupMessage = "";
this.setClubImages({
icon: (this.$refs.icon as HTMLInputElement).files?.[0],
logo: (this.$refs.logo as HTMLInputElement).files?.[0],
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -0,0 +1,95 @@
<template>
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-center">Mailversand</p>
<div class="-space-y-px">
<div class="mb-2">
<input id="mail" name="mail" type="email" placeholder="Mailadresse" required autocomplete="email" />
</div>
<div>
<input
id="user"
name="user"
type="text"
placeholder="Benutzername (kann auch Mail sein)"
required
autocomplete="username"
class="rounded-b-none!"
/>
</div>
<div>
<input
id="password"
name="password"
type="password"
placeholder="Passwort"
required
autocomplete="new-password"
class="rounded-none!"
/>
</div>
<div>
<input id="host" name="host" type="text" placeholder="Server-Host" required class="rounded-none!" />
</div>
<div>
<input id="port" name="port" type="number" placeholder="Port (25, 465, 587)" required class="rounded-t-none!" />
</div>
<div class="flex flex-row items-center gap-2 pt-1">
<input type="checkbox" id="secure" />
<label for="secure">SSL-Verbindung (setzen bei Port 465)</label>
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Mailversand speichern
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import { mapActions } from "pinia";
import { useSetupStore } from "../../stores/setup";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
...mapActions(useSetupStore, ["setMailConfig", "skip"]),
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.setMailConfig({
host: formData.host.value,
port: formData.port.value,
secure: formData.secure.checked,
mail: formData.mail.value,
username: formData.user.value,
password: formData.password.value,
})
.then((result) => {
// this.setupStatus = "success";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
},
});
</script>

View file

@ -18,7 +18,7 @@
--error: #9a0d55;
--warning: #bb6210;
--info: #388994;
--success: #73ad0f;
--success: #7ac142;
}
.dark {
--primary: #ff0d00;
@ -27,7 +27,7 @@
--error: #9a0d55;
--warning: #bb6210;
--info: #4ccbda;
--success: #73ad0f;
--success: #7ac142;
}
}

130
src/stores/setup.ts Normal file
View file

@ -0,0 +1,130 @@
import { defineStore } from "pinia";
import { http } from "../serverCom";
import type { AxiosResponse } from "axios";
import { useConfigurationStore } from "./configuration";
export const useSetupStore = defineStore("setup", {
state: () => {
return {
dictionary: ["club", "clubImages", "app", "mail", "account", "finished"],
step: 0 as number,
successfull: 0 as number,
};
},
getters: {
stepIndex: (state) => (dict: string) => state.dictionary.findIndex((d) => d == dict),
},
actions: {
skip(dict: string) {
let myIndex = this.stepIndex(dict);
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex + 1;
}
},
async setClub(data: {
name?: string;
imprint?: string;
privacy?: string;
website?: string;
}): Promise<AxiosResponse<any, any>> {
let configStore = useConfigurationStore();
let myIndex = this.stepIndex("club");
const res = await http.post(`/setup/club`, {
name: data.name,
imprint: data.imprint,
privacy: data.privacy,
website: data.website,
});
configStore.configure();
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async setClubImages(data: { icon?: File; logo?: File }): Promise<AxiosResponse<any, any>> {
let configStore = useConfigurationStore();
let myIndex = this.stepIndex("clubImages");
const formData = new FormData();
if (data.icon) {
formData.append("icon", data.icon);
}
if (data.logo) {
formData.append("logo", data.logo);
}
const res = await http.post(`/setup/club/images`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
configStore.configure();
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async setApp(data: { login_message: string; show_cal_link: boolean }): Promise<AxiosResponse<any, any>> {
let myIndex = this.stepIndex("app");
const res = await http.post(`/setup/app`, {
custom_login_message: data.login_message,
show_link_to_calendar: data.show_cal_link,
});
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async setMailConfig(data: {
host: string;
port: number;
secure: boolean;
mail: string;
username: string;
password: string;
}): Promise<AxiosResponse<any, any>> {
let myIndex = this.stepIndex("mail");
const res = await http.post(`/setup/mail`, {
host: data.host,
port: data.port,
secure: data.secure,
mail: data.mail,
username: data.username,
password: data.password,
});
this.step += 1;
if (this.successfull <= myIndex) {
this.successfull = myIndex;
}
return res;
},
async createAdmin(credentials: {
username: string;
mail: string;
firstname: string;
lastname: string;
}): Promise<AxiosResponse<any, any>> {
let myIndex = this.stepIndex("account");
const res = await http.post(`/setup/me`, {
username: credentials.username,
mail: credentials.mail,
firstname: credentials.firstname,
lastname: credentials.lastname,
});
this.step += 1;
if (this.successfull < myIndex) {
this.successfull = myIndex;
}
return res;
},
},
});

View file

@ -5,33 +5,15 @@
<AppLogo />
<h2 class="text-center text-4xl font-extrabold text-gray-900">Einrichtung</h2>
</div>
<CheckProgressBar :total="dictionary.length" :step="step" :successfull="successfull" />
<form class="flex flex-col gap-2" @submit.prevent="setup">
<div class="-space-y-px">
<div>
<input id="username" name="username" type="text" required placeholder="Benutzer" class="rounded-b-none!" />
</div>
<div>
<input id="mail" name="mail" type="email" required placeholder="Mailadresse" class="rounded-none!" />
</div>
<div>
<input id="firstname" name="firstname" type="text" required placeholder="Vorname" class="rounded-none!" />
</div>
<div>
<input id="lastname" name="lastname" type="text" required placeholder="Nachname" class="rounded-t-none!" />
</div>
</div>
<div class="flex flex-row gap-2">
<button type="submit" primary :disabled="setupStatus == 'loading' || setupStatus == 'success'">
Admin-Account anlegen
</button>
<Spinner v-if="setupStatus == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="setupStatus == 'success'" />
<FailureXMark v-else-if="setupStatus == 'failed'" />
</div>
<p v-if="setupMessage" class="text-center">{{ setupMessage }}</p>
</form>
<Club v-if="step == stepIndex('club')" />
<Images v-else-if="step == stepIndex('clubImages')" />
<App v-else-if="step == stepIndex('app')" />
<Mail v-else-if="step == stepIndex('mail')" />
<Account v-else-if="step == stepIndex('account')" />
<Finished v-else-if="step == stepIndex('finished')" />
<p v-else class="text-center">UI-Fehler - versuchen Sie einen Reload der Seite</p>
<FormBottomBar />
</div>
@ -40,42 +22,23 @@
<script setup lang="ts">
import { defineComponent } from "vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import FormBottomBar from "@/components/FormBottomBar.vue";
import AppLogo from "@/components/AppLogo.vue";
import App from "@/components/setup/App.vue";
import Account from "@/components/setup/Account.vue";
import CheckProgressBar from "@/components/CheckProgressBar.vue";
import { mapState } from "pinia";
import { useSetupStore } from "@/stores/setup";
import Club from "@/components/setup/Club.vue";
import Images from "@/components/setup/Images.vue";
import Finished from "@/components/setup/Finished.vue";
import Mail from "../../components/setup/Mail.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
setupStatus: undefined as undefined | "loading" | "success" | "failed",
setupMessage: "" as string,
};
},
methods: {
setup(e: any) {
let formData = e.target.elements;
this.setupStatus = "loading";
this.setupMessage = "";
this.$http
.post(`/setup/me`, {
username: formData.username.value,
mail: formData.mail.value,
firstname: formData.firstname.value,
lastname: formData.lastname.value,
})
.then((result) => {
this.setupStatus = "success";
this.setupMessage = "Sie haben einen Verifizierungslink per Mail erhalten.";
})
.catch((err) => {
this.setupStatus = "failed";
this.setupMessage = err.response.data;
});
},
computed: {
...mapState(useSetupStore, ["step", "stepIndex", "successfull", "dictionary"]),
},
});
</script>